// Package setup contains speaker migration and configuration helpers. package setup import ( "encoding/xml" "errors" "fmt" "io" "log" "net" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "time" "github.com/gesellix/bose-soundtouch/pkg/client" "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gesellix/bose-soundtouch/pkg/service/certmanager" "github.com/gesellix/bose-soundtouch/pkg/service/constants" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" "github.com/gesellix/bose-soundtouch/pkg/service/marge" "github.com/gesellix/bose-soundtouch/pkg/ssh" "github.com/gesellix/bose-soundtouch/pkg/telnet" ) // MigrationMethod represents the method used to migrate a speaker. type MigrationMethod string const ( // MigrationMethodXML redirects services by modifying SoundTouchSdkPrivateCfg.xml. MigrationMethodXML MigrationMethod = "xml" // MigrationMethodHosts redirects services by modifying /etc/hosts and updating the CA trust store. MigrationMethodHosts MigrationMethod = "hosts" // MigrationMethodResolvConf redirects services by injecting a priority DNS hook into the DHCP logic and updating the CA trust store. MigrationMethodResolvConf MigrationMethod = "resolv" // MigrationMethodTelnet redirects services by driving the device's diagnostic // shell on TCP port 17000. Requires no SSH access on the device. MigrationMethodTelnet MigrationMethod = "telnet" ) // SoundTouchSdkPrivateCfgPath is the path to the speaker's private configuration file on device. const SoundTouchSdkPrivateCfgPath = "/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml" // PrivateCfg represents the SoundTouchSdkPrivateCfg XML structure. type PrivateCfg struct { XMLName xml.Name `xml:"SoundTouchSdkPrivateCfg" json:"-"` MargeServerUrl string `xml:"margeServerUrl" json:"margeServerUrl"` StatsServerUrl string `xml:"statsServerUrl" json:"statsServerUrl"` SwUpdateUrl string `xml:"swUpdateUrl" json:"swUpdateUrl"` UsePandoraProductionServer bool `xml:"usePandoraProductionServer" json:"usePandoraProductionServer"` IsZeroconfEnabled bool `xml:"isZeroconfEnabled" json:"isZeroconfEnabled"` SaveMargeCustomerReport bool `xml:"saveMargeCustomerReport" json:"saveMargeCustomerReport"` BmxRegistryUrl string `xml:"bmxRegistryUrl" json:"bmxRegistryUrl"` } // MigrationSummary provides details about the state of a speaker before migration. type MigrationSummary struct { SSHSuccess bool `json:"ssh_success"` CurrentConfig string `json:"current_config"` PlannedConfig string `json:"planned_config"` OriginalConfig string `json:"original_config,omitempty"` ParsedCurrentConfig *PrivateCfg `json:"parsed_current_config,omitempty"` PlannedHosts string `json:"planned_hosts,omitempty"` RemoteServicesEnabled bool `json:"remote_services_enabled"` RemoteServicesPersistent bool `json:"remote_services_persistent"` RemoteServicesFound []string `json:"remote_services_found"` RemoteServicesCheckErr string `json:"remote_services_check_err,omitempty"` DeviceName string `json:"device_name,omitempty"` DeviceModel string `json:"device_model,omitempty"` DeviceSerial string `json:"device_serial,omitempty"` DeviceID string `json:"device_id,omitempty"` AccountID string `json:"account_id,omitempty"` FirmwareVersion string `json:"firmware_version,omitempty"` CACertTrusted bool `json:"ca_cert_trusted"` ServerHTTPSURL string `json:"server_https_url,omitempty"` CurrentResolvConf string `json:"current_resolv_conf,omitempty"` PlannedResolv string `json:"planned_resolv,omitempty"` IsMigrated bool `json:"is_migrated"` // Per-axis migration signals — IsMigrated is the OR of these. The UI // displays them individually so users can see partial states (e.g. // URLs flipped via telnet but the on-disk XML hasn't caught up, or // DNS interception in place but no CA installed). XMLMigrated bool `json:"xml_migrated"` HostsMigrated bool `json:"hosts_migrated"` ResolvMigrated bool `json:"resolv_migrated"` TelnetMigrated bool `json:"telnet_migrated"` // IsPaired reports whether the device's live :8090/info advertises a // non-empty margeAccountUUID. Surfaced separately so the wizard can // flag pairing as a precondition independently of the URL flip. IsPaired bool `json:"is_paired"` ResolveIPError string `json:"resolve_ip_error,omitempty"` // ResolveIPSource records where the resolved IP came from: // "device" — authoritative answer via SSH ping (preferred for // migration). "service" — service-side DNS lookup (fast but may // differ when NAT or split-DNS is in play). Empty when host was // already an IP literal or could not be resolved at all. ResolveIPSource string `json:"resolve_ip_source,omitempty"` // ResolveIPDurationMS measures how long the resolve call took // (wall-clock, milliseconds). Captured during preflight so we can // observe the SSH-ping cost in the wild. ResolveIPDurationMS int64 `json:"resolve_ip_duration_ms,omitempty"` // Telnet (port 17000) preflight state — populated when the user is about to // or has just used MigrationMethodTelnet. TelnetReachable bool `json:"telnet_reachable"` TelnetBanner string `json:"telnet_banner,omitempty"` TelnetVerifiedConfig string `json:"telnet_verified_config,omitempty"` TelnetProbeError string `json:"telnet_probe_error,omitempty"` // 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. // // Connect/Close are optional: Run/UploadContent both work standalone // (dialing their own one-off connection each time, as they always have). // Call Connect first when making several calls in a row — e.g. // RevertMigration's ~17 commands — so they reuse one connection instead of // dialing fresh every time; defer Close to release it afterward. type SSHClient interface { Run(command string) (string, error) UploadContent(content []byte, remotePath string) error Connect() error Close() error } // TelnetClient defines the interface for the device's port-17000 diagnostic // shell. The concrete implementation lives in github.com/gesellix/bose-soundtouch/pkg/telnet; // the interface exists so tests can substitute a mock. type TelnetClient interface { Dial() error Probe() (string, error) SendCommand(cmd string) (string, error) Close() error } // Manager handles the migration of speakers to the service. type Manager struct { ServerURL string DataStore *datastore.DataStore Crypto *certmanager.CertificateManager NewSSH func(host string) SSHClient NewTelnet func(host string) TelnetClient // NewSession opens the WebSocket setup state-machine session used // by ExecuteInitPlan. Tests inject an in-memory fake; the production // default is DialSession. NewSession func(deviceIP, deviceID string, stepTimeout time.Duration) (StateMachine, error) // GetDNSRunning is an optional callback to check the actual state of the DNS server. GetDNSRunning func() (bool, string) // HTTPGet is an optional override for http.Get (primarily for testing). HTTPGet func(url string) (*http.Response, error) // Spotify management credentials for the boot primer MgmtUsername string MgmtPassword string } // NewManager creates a new Manager with the given base server URL. func NewManager(serverURL string, ds *datastore.DataStore, cm *certmanager.CertificateManager) *Manager { return &Manager{ ServerURL: serverURL, DataStore: ds, Crypto: cm, NewSSH: func(host string) SSHClient { return ssh.NewClient(host) }, NewTelnet: func(host string) TelnetClient { return telnet.NewClient(host) }, NewSession: func(deviceIP, deviceID string, stepTimeout time.Duration) (StateMachine, error) { return DialSession(deviceIP, deviceID, SessionConfig{StepTimeout: stepTimeout}) }, // Bound the speaker GETs (/info, /presets, /recents, /sources, // inspect, peer probes). The default http.Get uses // http.DefaultClient, which has no timeout, so an offline speaker // hangs the caller for the full OS-level TCP timeout (~30 s). That // is fine for a one-off, but the admin device list refreshes every // device's live /info on each load, so a few offline speakers used // to stall the page for 30 s each. A healthy speaker answers in well // under a second on the LAN; liveDeviceHTTPTimeout fails the dead // ones fast instead. HTTPGet: (&http.Client{Timeout: liveDeviceHTTPTimeout}).Get, MgmtUsername: "admin", MgmtPassword: "change_me!", } } // liveDeviceHTTPTimeout bounds the plain HTTP GETs the Manager makes to a // speaker's :8090 endpoints. Generous enough for a healthy speaker on a // busy LAN, short enough that an offline speaker fails quickly rather than // holding a request (and a browser connection slot) for ~30 s. const liveDeviceHTTPTimeout = 5 * time.Second // DeviceInfoXML represents the XML structure from :8090/info type DeviceInfoXML struct { XMLName xml.Name `xml:"info" json:"-"` DeviceID string `xml:"deviceID,attr" json:"deviceID"` Name string `xml:"name" json:"name"` Type string `xml:"type" json:"type"` ModuleType string `xml:"moduleType" json:"moduleType"` MargeAccountUUID string `xml:"margeAccountUUID" json:"margeAccountUUID"` MargeURL string `xml:"margeURL" json:"margeURL"` CountryCode string `xml:"countryCode" json:"countryCode"` RegionCode string `xml:"regionCode" json:"regionCode"` Variant string `xml:"variant" json:"variant"` VariantMode string `xml:"variantMode" json:"variantMode"` Components []struct { Category string `xml:"componentCategory"` SoftwareVersion string `xml:"softwareVersion"` SerialNumber string `xml:"serialNumber"` } `xml:"components>component" json:"-"` NetworkInfo []struct { Type string `xml:"type,attr"` MacAddress string `xml:"macAddress"` IPAddress string `xml:"ipAddress"` } `xml:"networkInfo" json:"networkInfo"` SoftwareVer string `xml:"-" json:"softwareVersion"` SerialNumber string `xml:"-" json:"serialNumber"` } // GetLiveDeviceInfo fetches live information from the speaker's :8090/info endpoint. func (m *Manager) GetLiveDeviceInfo(deviceIP string) (*DeviceInfoXML, error) { infoURL := fmt.Sprintf("http://%s:8090/info", deviceIP) // For testing, if the IP already contains a port, don't append :8090 if host, _, err := net.SplitHostPort(deviceIP); err == nil { infoURL = fmt.Sprintf("http://%s/info", deviceIP) _ = host } resp, err := m.HTTPGet(infoURL) if err != nil { return nil, fmt.Errorf("failed to fetch info from %s: %w", infoURL, err) } defer func() { _ = resp.Body.Close() }() var infoXML DeviceInfoXML if err := m.parseDeviceInfoXML(resp.Body, &infoXML); err != nil { return nil, fmt.Errorf("failed to decode info XML from %s: %w", infoURL, err) } return &infoXML, nil } // parseDeviceInfoXML is a helper method for parsing device info XML from a reader func (m *Manager) parseDeviceInfoXML(reader io.Reader, infoXML *DeviceInfoXML) error { if err := xml.NewDecoder(reader).Decode(infoXML); err != nil { return err } // Extract data from components for _, comp := range infoXML.Components { switch comp.Category { case "SCM": infoXML.SoftwareVer = comp.SoftwareVersion if infoXML.SerialNumber == "" { infoXML.SerialNumber = comp.SerialNumber } case "PackagedProduct": if infoXML.SerialNumber == "" { infoXML.SerialNumber = comp.SerialNumber } } } return nil } // GetPrimaryMacAddress returns the primary MAC address from the SCM network interface. func (d *DeviceInfoXML) GetPrimaryMacAddress() string { for _, net := range d.NetworkInfo { if net.Type == "SCM" && net.MacAddress != "" { return net.MacAddress } } return "" } // GetMigrationSummary returns a summary of the current and planned state of the speaker. func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, options map[string]string) (*MigrationSummary, error) { if targetURL == "" { targetURL = m.ServerURL } summary := &MigrationSummary{ SSHSuccess: false, } // Run the telnet preflight in parallel with the SSH-based probes below. // Both transports are queried independently: SSH gives access to // /etc/hosts, /etc/resolv.conf and the on-device XML config; telnet's // `getpdo CurrentSystemConfiguration` reports the live URL set without // needing root. They are complementary, so we wait for both and merge // the results — total wall time = max(ssh, telnet). telnetCh := make(chan MigrationSummary, 1) go func() { var local MigrationSummary m.telnetPreflight(&local, deviceIP) telnetCh <- local }() // Populate device info from datastore and live info m.populateDeviceInfo(summary, deviceIP) // 1. Initial planned config plannedCfg := PrivateCfg{ MargeServerUrl: targetURL, StatsServerUrl: targetURL, SwUpdateUrl: fmt.Sprintf("%s/updates/soundtouch", targetURL), UsePandoraProductionServer: true, IsZeroconfEnabled: true, SaveMargeCustomerReport: false, BmxRegistryUrl: fmt.Sprintf("%s/bmx/registry/v1/services", targetURL), } // 2. One batched SSH round-trip collects every file/existence probe // we need. Without this, the legacy per-helper path issued ~8 fresh // SSH dials in sequence — each pkg/ssh.Run() opens a brand-new // TCP+SSH handshake on legacy crypto, ~500 ms–1 s each. probe := m.probeSpeakerSSH(deviceIP) m.applyProbeToSummary(summary, probe, &plannedCfg, proxyURL, targetURL, options) // Per-field literal URL overrides win over both the canonical // derivation and any self/proxied/original mode applied above — // the user picked a URL, so the planned preview reflects exactly // what the XML migration will write. applyURLOverrides(&plannedCfg, options) xmlContent, err := xml.MarshalIndent(plannedCfg, "", " ") if err != nil { return nil, fmt.Errorf("failed to marshal planned XML: %w", err) } summary.PlannedConfig = "\n" + string(xmlContent) // 2b. Planned network config (hosts entries, resolv.conf preview, resolve error). // Pass an SSH client only when the probe succeeded — opening a fresh // dial when we already know SSH is dead would burn ~handshake-timeout // of wall time per refresh. var resolveClient SSHClient if probe.SSHOK && m.NewSSH != nil { resolveClient = m.NewSSH(deviceIP) } m.populatePlannedNetworkConfig(summary, deviceIP, targetURL, resolveClient) // 3. Provide HTTPS URL for testing (consumed by the migration UI) summary.ServerHTTPSURL = m.buildServerHTTPSURL(targetURL) // 5. Merge telnet preflight results (started in parallel at the top). telnetResult := <-telnetCh summary.TelnetReachable = telnetResult.TelnetReachable summary.TelnetBanner = telnetResult.TelnetBanner summary.TelnetVerifiedConfig = telnetResult.TelnetVerifiedConfig summary.TelnetProbeError = telnetResult.TelnetProbeError // 6. Check if migrated (must run after telnet results are merged so // TelnetVerifiedConfig is populated). XML/hosts/resolv axes use the // probe data already gathered above. m.checkIsMigratedFromProbe(summary, probe) // 7. Cross-check SSH-XML and telnet-getpdo readings; surface any // divergence as a non-fatal warning. m.crossCheckPreflights(summary) return summary, nil } func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, _, targetURL string, sshClient SSHClient) { parsedURL, err := url.Parse(targetURL) if err != nil { return } hostName := parsedURL.Hostname() if hostName == "" || hostName == "localhost" { return } // Resolve the target hostname. When the caller provides an SSH // client (i.e. the speaker already answered the probe), we prefer // the device-side lookup — it's authoritative for the actual // network path the speaker will use. When SSH isn't available we // fall back to service-side DNS and tag the result with // ErrResolvedFromServiceOnly so the summary can render it as // informational rather than as a hard error. // // Historical note: the SSH path was previously skipped here for // cost reasons (a comment claimed "2–5 s extra per preflight // refresh"). Measured 2026-05-16 across ST10 + ST20 on firmware // 27.0.6.46330.5043500: ~290 ms ± 10 ms per resolve, three runs. // Well under the original estimate — promoted to the default path. // ResolveIPDurationMS stays on the summary so any regression // (firmware upgrade, slower kex, etc.) is visible. start := time.Now() hostIP, resolveErr := m.resolveIP(hostName, sshClient) summary.ResolveIPDurationMS = time.Since(start).Milliseconds() switch { case resolveErr == nil && hostIP != "": summary.ResolveIPSource = "device" if sshClient == nil { // Caller didn't ask for the SSH path, and we got a clean // answer — that only happens when host was already an IP // literal. Source is neither "device" nor "service" in a // meaningful sense; leave it empty. summary.ResolveIPSource = "" } case errors.Is(resolveErr, ErrResolvedFromServiceOnly): summary.ResolveIPSource = "service" // Sentinel-tagged errors are informational — the resolved IP // is still usable for the preview, the caller just shouldn't // treat it as authoritative. We do NOT populate ResolveIPError // here; the CLI/UI use that field for hard failures only. case resolveErr != nil: summary.ResolveIPError = resolveErr.Error() } if hostIP == "" { hostIP = hostName } summary.PlannedResolv = fmt.Sprintf("# Created by Aftertouch/SoundTouch-Service\n# Priority nameserver for Bose service redirection\nnameserver %s\n", hostIP) domains := []string{ "streaming.bose.com", "updates.bose.com", "stats.bose.com", "bmx.bose.com", "content.api.bose.io", "events.api.bosecm.com", // app_key validation for /speaker audio notifications (TTS). Without // these redirects the speaker validates against the dead Bose cloud and // reports an invalid app key. Both the prod and dev hosts are seeded // (firmware may use either). DNS interception already covers them via // the bosecm.com substring; seed here for /etc/hosts migrations too. "audionotification.api.bosecm.com", "audionotificationdev.api.bosecm.com", "bose-prod.apigee.net", "worldwide.bose.com", "music.api.bose.com", "media.bose.io", "downloads.bose.com", "voice.api.bose.io", } hostsLines := make([]string, len(domains)) for i, domain := range domains { hostsLines[i] = fmt.Sprintf("%s\t%s", hostIP, domain) } summary.PlannedHosts = strings.Join(hostsLines, "\n") } // buildServerHTTPSURL composes the AfterTouch /health probe URL. // // Port resolution order (first non-empty wins): // 1. The port from targetURL when it is already an https:// URL — the // caller supplied an HTTPS endpoint, so it owns the port choice. // 2. The implicit https:// default 443 when targetURL is https:// with // no explicit port. // 3. The HTTPS_PORT env var — back-compat for deployments where // targetURL is http://…:8000 and HTTPS_PORT names the separate TLS // listener. // 4. The legacy default 8443. func (m *Manager) buildServerHTTPSURL(targetURL string) string { parsedURL, err := url.Parse(targetURL) if err != nil || parsedURL.Hostname() == "" { return "" } var httpsPort string if parsedURL.Scheme == "https" { httpsPort = parsedURL.Port() if httpsPort == "" { httpsPort = "443" } } if httpsPort == "" { httpsPort = os.Getenv("HTTPS_PORT") } if httpsPort == "" { httpsPort = "8443" } return fmt.Sprintf("https://%s:%s/health", parsedURL.Hostname(), httpsPort) } // checkIsMigrated determines if the device is already migrated to // AfterTouch and which mechanism is in place. // // Each axis is recorded as a separate boolean so the UI can show // partial-state cells (e.g. URLs flipped via telnet but the on-disk XML // hasn't been re-rendered, or DNS interception present but no CA // installed). IsMigrated is the OR — if any mechanism reports the // device pointing at our service, the device is "migrated." // // The telnet-based check runs unconditionally because it is the only // migration-state signal available on devices that do not expose SSH // (USB-unlock-refusing firmware on SA-5, ST520, recent ST Portable). // The SSH-based checks need a working shell and cover the /etc/hosts // and /etc/resolv.conf interception variants, neither of which shows // up in `getpdo CurrentSystemConfiguration`. func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) { summary.TelnetMigrated = m.isTelnetMigrated(summary) if summary.SSHSuccess { client := m.NewSSH(deviceIP) summary.XMLMigrated = m.isXMLMigrated(summary) summary.HostsMigrated = m.isHostsMigrated(client, summary) summary.ResolvMigrated = m.isResolvConfMigrated(client, summary) } summary.IsMigrated = summary.TelnetMigrated || summary.XMLMigrated || summary.HostsMigrated || summary.ResolvMigrated } // isTelnetMigrated reports whether the live device config (read via the // telnet preflight's `getpdo CurrentSystemConfiguration`) already points // at our service. Mirrors isXMLMigrated's substring-match semantics — any // occurrence of our hostname in the response is enough. func (m *Manager) isTelnetMigrated(summary *MigrationSummary) bool { if summary.TelnetVerifiedConfig == "" { return false } parsedTarget, err := url.Parse(m.ServerURL) if err != nil { return false } targetHost := parsedTarget.Hostname() if targetHost == "" { return false } return strings.Contains(summary.TelnetVerifiedConfig, targetHost) } // isXMLMigrated checks whether current XML config already points to our server. func (m *Manager) isXMLMigrated(summary *MigrationSummary) bool { if summary.ParsedCurrentConfig == nil { return false } parsedTarget, err := url.Parse(m.ServerURL) if err != nil { return false } targetHost := parsedTarget.Hostname() if targetHost == "" { return false } return strings.Contains(summary.ParsedCurrentConfig.MargeServerUrl, targetHost) || strings.Contains(summary.ParsedCurrentConfig.StatsServerUrl, targetHost) || strings.Contains(summary.ParsedCurrentConfig.SwUpdateUrl, targetHost) || strings.Contains(summary.ParsedCurrentConfig.BmxRegistryUrl, targetHost) } // isHostsMigrated checks if /etc/hosts contains Bose domain redirections and CA is trusted. func (m *Manager) isHostsMigrated(client SSHClient, summary *MigrationSummary) bool { hostsContent, err := client.Run("cat /etc/hosts") if err != nil { return false } boseDomains := []string{ "streaming.bose.com", "updates.bose.com", "stats.bose.com", "bmx.bose.com", } for _, domain := range boseDomains { if strings.Contains(hostsContent, domain) && summary.CACertTrusted { return true } } return false } // isResolvConfMigrated checks for Aftertouch DNS migration signals and CA trust. func (m *Manager) isResolvConfMigrated(client SSHClient, summary *MigrationSummary) bool { // Hook file present if _, err := client.Run("[ -f /mnt/nv/aftertouch.resolv.conf ]"); err == nil { return summary.CACertTrusted } if summary.CurrentResolvConf == "" { return false } // Marker comment present if strings.Contains(summary.CurrentResolvConf, "# Priority nameserver for Bose service redirection") && summary.CACertTrusted { return true } // Match hostname or resolved IP parsedTarget, err := url.Parse(m.ServerURL) if err != nil { return false } targetHost := parsedTarget.Hostname() if targetHost == "" { return false } if strings.Contains(summary.CurrentResolvConf, targetHost) && summary.CACertTrusted { return true } resolvedIP, _ := m.resolveIP(targetHost, client) if resolvedIP != "" && strings.Contains(summary.CurrentResolvConf, resolvedIP) && summary.CACertTrusted { return true } return false } // populateDeviceInfo fills in device information from datastore and live info func (m *Manager) populateDeviceInfo(summary *MigrationSummary, deviceIP string) { // Populate from datastore if available if m.DataStore != nil { devices, err := m.DataStore.ListAllDevices() if err == nil { for i := range devices { d := devices[i] if d.IPAddress != deviceIP { continue } summary.DeviceName = d.Name summary.DeviceModel = d.ProductCode summary.DeviceSerial = d.DeviceSerialNumber summary.DeviceID = d.DeviceID summary.AccountID = d.AccountID summary.FirmwareVersion = d.FirmwareVersion break } } } // Supplement with live info from :8090/info if infoXML, err := m.GetLiveDeviceInfo(deviceIP); err == nil { if infoXML.Name != "" { summary.DeviceName = infoXML.Name } if infoXML.Type != "" { summary.DeviceModel = infoXML.Type } if infoXML.SerialNumber != "" { summary.DeviceSerial = infoXML.SerialNumber } if infoXML.SoftwareVer != "" { summary.FirmwareVersion = infoXML.SoftwareVer } if infoXML.DeviceID != "" { summary.DeviceID = infoXML.DeviceID } if infoXML.MargeAccountUUID != "" { summary.AccountID = infoXML.MargeAccountUUID } } // Pairing state is derived from the live :8090/info value above // (which clobbers the stale datastore copy if both are present). // An empty AccountID at this point means a fresh / factory-reset // device that needs pairing before presets and streaming work. summary.IsPaired = summary.AccountID != "" } // checkCurrentConfig reads and validates the current speaker configuration func (m *Manager) checkCurrentConfig(summary *MigrationSummary, deviceIP string) (string, error) { path := SoundTouchSdkPrivateCfgPath client := m.NewSSH(deviceIP) // Check if .original exists if _, checkErr := client.Run(fmt.Sprintf("[ -f %s.original ]", path)); checkErr == nil { if originalConfig, _ := client.Run(fmt.Sprintf("cat %s.original", path)); originalConfig != "" { summary.OriginalConfig = originalConfig } } // Try to read current config config, err := client.Run(fmt.Sprintf("cat %s", path)) if err == nil && config != "" { summary.SSHSuccess = true return config, nil } // Fallback: try base64 if cat returned empty string but file has size > 0 if config == "" { if fileInfo, _ := client.Run(fmt.Sprintf("ls -l %s", path)); fileInfo != "" { if b64Config, configErr := client.Run(fmt.Sprintf("base64 %s", path)); configErr == nil && b64Config != "" { // File exists but couldn't read content properly summary.SSHSuccess = true summary.CurrentConfig = fmt.Sprintf("Error reading config: %v", err) return "", fmt.Errorf("config file exists but couldn't read content") } } } // If SSH failed or file couldn't be read, check if SSH connection works at all if _, sshErr := client.Run("ls /"); sshErr == nil { summary.SSHSuccess = true if err != nil { summary.CurrentConfig = fmt.Sprintf("Error reading config: %v", err) } else { summary.CurrentConfig = config // Might be empty } } else { summary.SSHSuccess = false summary.CurrentConfig = fmt.Sprintf("SSH connection failed: %v", sshErr) } return "", err } // applyProxyOptions modifies planned config based on proxy options. // Each field accepts: "proxied" (route through proxyURL), "original" (keep current value), or unset (use targetURL via plannedCfg default). func (m *Manager) applyProxyOptions(plannedCfg *PrivateCfg, proxyURL string, options map[string]string, currentCfg *PrivateCfg) { if currentCfg == nil { return } if options["marge"] == "proxied" && currentCfg.MargeServerUrl != "" { plannedCfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl) } else if options["marge"] == "original" && currentCfg.MargeServerUrl != "" { plannedCfg.MargeServerUrl = currentCfg.MargeServerUrl } if options["stats"] == "proxied" && currentCfg.StatsServerUrl != "" { plannedCfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl) } else if options["stats"] == "original" && currentCfg.StatsServerUrl != "" { plannedCfg.StatsServerUrl = currentCfg.StatsServerUrl } if options["sw_update"] == "proxied" && currentCfg.SwUpdateUrl != "" { plannedCfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl) } else if options["sw_update"] == "original" && currentCfg.SwUpdateUrl != "" { plannedCfg.SwUpdateUrl = currentCfg.SwUpdateUrl } if options["bmx"] == "proxied" && currentCfg.BmxRegistryUrl != "" { plannedCfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl) } else if options["bmx"] == "original" && currentCfg.BmxRegistryUrl != "" { plannedCfg.BmxRegistryUrl = currentCfg.BmxRegistryUrl } } // applyURLOverrides applies per-field literal URL overrides from the // migration options map (marge_url / stats_url / sw_update_url / // bmx_url) on top of an already-populated PrivateCfg. Empty or missing // entries leave the field unchanged. // // These overrides win over the legacy "self/proxied/original" semantic // applied by applyProxyOptions: if the user picked a literal URL, the // migration honors it verbatim. The XML and Telnet write paths and // the GetMigrationSummary read path all call this so the planned // preview matches what migration actually writes. func applyURLOverrides(cfg *PrivateCfg, options map[string]string) { if cfg == nil || options == nil { return } if v := options["marge_url"]; v != "" { cfg.MargeServerUrl = v } if v := options["stats_url"]; v != "" { cfg.StatsServerUrl = v } if v := options["sw_update_url"]; v != "" { cfg.SwUpdateUrl = v } if v := options["bmx_url"]; v != "" { cfg.BmxRegistryUrl = v } } // checkCACertTrusted decides whether the device already trusts *this* service's // CA. // // When the service CA is available (Manager.Crypto set), the certificate // *payload* is authoritative: a previous migration may have left our label // (CALabel) in the bundle even though the actual CA has since changed — e.g. the // service CA was regenerated after its data dir was recreated without a // persistent volume. Matching the label alone would then falsely report the // device as trusted and skip re-installing the new CA, leaving the speaker // unable to validate TLS to the service (the symptom seen in #517). So we match // the current CA's payload and deliberately ignore the (possibly stale) label. // // Only when the CA can't be read (e.g. a CLI caller without Crypto) do we fall // back to the label, which is the best signal available there. func (m *Manager) checkCACertTrusted(summary *MigrationSummary, deviceIP string) { client := m.NewSSH(deviceIP) bundlePath := "/etc/pki/tls/certs/ca-bundle.crt" if m.Crypto != nil { if certData, ok := m.firstCACertBodyLine(); ok { // Payload present -> trusted; absent -> not trusted (re-install), // regardless of a possibly-stale label. if _, err := client.Run(fmt.Sprintf("grep -F %q %s", certData, bundlePath)); err == nil { summary.CACertTrusted = true } return } } // Fallback for callers without the CA at hand: match our injected label. output, err := client.Run(fmt.Sprintf("grep -F %q %s", CALabel, bundlePath)) if err == nil && strings.Contains(output, CALabel) { summary.CACertTrusted = true } } // firstCACertBodyLine returns the first base64 body line of the service CA // certificate, used as a cheap fingerprint to check whether this exact CA is // already present in a device's trust bundle. Returns false when the cert can't // be read or has no body line. func (m *Manager) firstCACertBodyLine() (string, bool) { caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath()) if err != nil { return "", false } for _, line := range strings.Split(string(caCertPEM), "\n") { if line != "" && !strings.Contains(line, "BEGIN CERTIFICATE") && !strings.Contains(line, "END CERTIFICATE") { return line, true } } return "", false } // MigrateSpeaker configures the speaker at the given IP to use this service. func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options map[string]string, method MigrationMethod) (string, error) { if targetURL == "" { targetURL = m.ServerURL } if method == "" { method = MigrationMethodXML } // Telnet is SSH-free by design — skip the SSH-based off-device backup and // rw pre-flight, both of which would fail on devices that haven't been // rooted via remote_services. if method == MigrationMethodTelnet { urls := telnetURLsFromOptions(targetURL, options) return m.migrateViaTelnet(deviceIP, targetURL, urls) } var logs string // 0. Off-device backup for safety if backupErr := m.BackupConfigOffDevice(deviceIP); backupErr != nil { logs += fmt.Sprintf("Warning: Failed to create off-device backup: %v\n", backupErr) // We continue, but this is a warning } else { logs += "Successfully created off-device backup of current configuration.\n" } // 0b. Pre-flight check for SSH /rw permissions client := m.NewSSH(deviceIP) rwCmd := "(rw || mount -o remount,rw /)" if rwTest, rwErr := client.Run(rwCmd); rwErr != nil { return logs, fmt.Errorf("pre-flight check failed: cannot gain write access (cmd: %s, output: %s): %w", rwCmd, rwTest, rwErr) } logs += "Pre-flight: Write access verified.\n" switch method { case MigrationMethodHosts: out, err := m.migrateViaHosts(deviceIP, targetURL) return logs + out, err case MigrationMethodResolvConf: if err := m.checkDNSPreFlight(); err != nil { return logs, err } out, err := m.migrateViaResolvConf(deviceIP, targetURL) return logs + out, err case MigrationMethodXML: out, err := m.migrateViaXML(deviceIP, targetURL, proxyURL, options, client, rwCmd) return logs + out, err default: return logs, fmt.Errorf("unsupported migration method: %s", method) } } func (m *Manager) checkDNSPreFlight() error { // CLI / remote callers construct a Manager without a DataStore — they // can't introspect AfterTouch's settings from here. Skip the local // check in that case; the caller is responsible for verifying the // remote service's DNS state (the CLI hits GET /setup/settings). if m.DataStore == nil { return nil } // Pre-flight check: DNS server must be enabled and bound to port 53 settings, err := m.DataStore.GetSettings() if err != nil { return fmt.Errorf("failed to retrieve settings: %w", err) } if !settings.DNSEnabled { return fmt.Errorf("DNS discovery server is not enabled. Please enable it in Settings before using /etc/resolv.conf migration") } if !strings.HasSuffix(settings.DNSBindAddr, ":53") && settings.DNSBindAddr != "53" { return fmt.Errorf("DNS discovery server is bound to %s, but port 53 is required for /etc/resolv.conf migration", settings.DNSBindAddr) } // Also check the actual running state if callback is available if m.GetDNSRunning != nil { isRunning, bindAddr := m.GetDNSRunning() if !isRunning { return fmt.Errorf("DNS discovery server is configured but not actually running on %s. Please check logs for binding errors", bindAddr) } if !strings.HasSuffix(bindAddr, ":53") && bindAddr != "53" { // This shouldn't happen based on previous check, but for completeness return fmt.Errorf("DNS discovery server is running on %s, but port 53 is required", bindAddr) } } return nil } func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options map[string]string, client SSHClient, rwCmd string) (string, error) { var logs string out, err := m.EnsureRemoteServices(deviceIP) logs += "Ensuring remote services:\n" + out + "\n" if err != nil { // Log but continue migration? Or fail? The requirement is "to ensure stable 'remote_services'" // Let's log it. fmt.Printf("Warning: failed to ensure remote services: %v\n", err) } cfg := PrivateCfg{ MargeServerUrl: targetURL, StatsServerUrl: targetURL, SwUpdateUrl: fmt.Sprintf("%s/updates/soundtouch", targetURL), UsePandoraProductionServer: true, IsZeroconfEnabled: true, SaveMargeCustomerReport: false, BmxRegistryUrl: fmt.Sprintf("%s/bmx/registry/v1/services", targetURL), } // If we can read current config, apply per-field options if curCfg, curCfgErr := client.Run(fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath)); curCfgErr == nil && curCfg != "" { logs += "Read current configuration\n" var currentCfg PrivateCfg if xml.Unmarshal([]byte(curCfg), ¤tCfg) == nil { if proxyURL == "" { proxyURL = targetURL } if options != nil { m.applyProxyOptions(&cfg, proxyURL, options, ¤tCfg) } else if proxyURL != "" { cfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl) cfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl) cfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl) cfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl) } } } // Per-field literal URL overrides take precedence over the // proxy/original modes applied above — see applyURLOverrides. applyURLOverrides(&cfg, options) xmlContent, err := xml.MarshalIndent(cfg, "", " ") if err != nil { return logs, fmt.Errorf("failed to marshal XML: %w", err) } // Add XML header xmlContent = append([]byte("\n"), xmlContent...) // 0. Backup original config if it doesn't exist remotePath := SoundTouchSdkPrivateCfgPath if backupOut, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err != nil { logs += fmt.Sprintf("Backing up original config to %s.original (check: %s)\n", remotePath, backupOut) fmt.Printf("Backing up original config to %s.original\n", remotePath) if output, err := client.Run(fmt.Sprintf("%s && cp %s %s.original", rwCmd, remotePath, remotePath)); err != nil { logs += fmt.Sprintf("cp backup failed: %v (output: %s)\n", err, output) fmt.Printf("cp backup failed: %v (output: %s)\n", err, output) if config, err := client.Run(fmt.Sprintf("cat %s", remotePath)); err == nil && config != "" { if err := client.UploadContent([]byte(config), remotePath+".original"); err != nil { logs += "failed to upload backup config: " + err.Error() + "\n" return logs, fmt.Errorf("cannot create backup of %s before migration: %w", remotePath, err) } logs += "Uploaded backup config via fallback\n" } else { return logs, fmt.Errorf("cannot create backup of %s before migration: failed to read original config", remotePath) } } else { logs += "Copied backup config to .original\n" } } else { logs += "Backup .original already exists\n" } // 1. Upload the configuration out, _ = client.Run(rwCmd) logs += rwCmd + ": " + out + "\n" if err := client.UploadContent(xmlContent, remotePath); err != nil { return logs, fmt.Errorf("failed to upload config: %w", err) } logs += "Uploaded new configuration to " + remotePath + "\n" // 2. Verify the configuration on device if verification, err := client.Run(fmt.Sprintf("cat %s", remotePath)); err == nil { if !strings.Contains(verification, cfg.MargeServerUrl) { return logs, fmt.Errorf("verification failed: uploaded config on %s does not contain expected margeServerUrl", deviceIP) } logs += "Verified configuration on device\n" } else { logs += fmt.Sprintf("Warning: could not verify configuration on device: %v\n", err) } // 3. Inject CA Certificate (optional but recommended) summary := &MigrationSummary{} m.checkCACertTrusted(summary, deviceIP) if !summary.CACertTrusted { out, err := m.TrustCACert(deviceIP) logs += "Trusting CA:\n" + out + "\n" if err != nil { fmt.Printf("Warning: failed to trust CA: %v\n", err) } } logs += m.resyncBoseURLsAfterXML(deviceIP, telnetURLs{ Marge: cfg.MargeServerUrl, Stats: cfg.StatsServerUrl, SwUpdate: cfg.SwUpdateUrl, BmxRegistry: cfg.BmxRegistryUrl, }) return logs, nil } // resyncBoseURLsAfterXML re-applies all four boseurls over telnet so the // runtime URL layer matches the XML just written by migrateViaXML. // // The XML migration only updates the persisted SoundTouchSdkPrivateCfg.xml; it // does not touch the runtime/persistence layer that `getpdo // CurrentSystemConfiguration` reports. When SSH was bootstrapped via #471 // (`enable-ssh`), that layer still points at the placeholder boseurls // (https://aftertouch.invalid), so the preflight cross-check keeps warning that // the URLs differ between transports until a reboot. // // All four fields are re-applied, not just marge/swUpdate: the closing // `envswitch boseurls set` commit persists whatever is currently in the // runtime layer at the moment it runs, not only its own two arguments (see // docs/content/docs/analysis/TELNET-COMMAND-REFERENCE.md). Committing while // stats/bmx are still stale in the runtime layer freezes those stale values // into the persistence layer permanently — a later reboot loads that frozen // persistence layer, not the XML file, so nothing short of a factory reset // clears it again. Re-applying the real boseurls over telnet :17000 // reconciles all four immediately. // // Best-effort: telnet may be unavailable (no port 17000, or it was closed via // --close-17000), in which case a reboot still reconciles the layers, so this // only returns a note and never fails the migration. Returns the log lines to // append. func (m *Manager) resyncBoseURLsAfterXML(deviceIP string, urls telnetURLs) string { if m.NewTelnet == nil { return "" } rlogs, rerr := m.setAllBoseURLsViaTelnet(deviceIP, urls) if rerr != nil { return fmt.Sprintf("Note: could not re-sync boseurls over telnet (%v); a device reboot will reconcile the runtime layer.\n", rerr) } return "Re-applied boseurls over telnet so the runtime layer matches the new configuration:\n" + rlogs } // BackupConfigOffDevice creates a local backup of the speaker's configuration files in the DataStore. func (m *Manager) BackupConfigOffDevice(deviceIP string) error { if m.DataStore == nil { return fmt.Errorf("datastore not configured") } client := m.NewSSH(deviceIP) // We need the serial number and account identifier to find the right directory in DataStore info, err := m.GetLiveDeviceInfo(deviceIP) if err != nil { return fmt.Errorf("failed to get device info: %w", err) } accountID := info.MargeAccountUUID deviceID := info.SerialNumber if deviceID == "" { deviceID = info.DeviceID } if deviceID == "" { deviceID = deviceIP } if accountID == "" { // Try to find account ID from existing device entries if info didn't have it devices, _ := m.DataStore.ListAllDevices() for i := range devices { if devices[i].DeviceSerialNumber == info.SerialNumber || (info.DeviceID != "" && devices[i].DeviceID == info.DeviceID) { accountID = devices[i].AccountID break } } } if accountID == "" { accountID = "default" } deviceDir := m.DataStore.AccountDeviceDir(accountID, deviceID) if err := os.MkdirAll(deviceDir, 0755); err != nil { return fmt.Errorf("failed to create device directory: %w", err) } // 1. Backup SoundTouchSdkPrivateCfg.xml if config, err := client.Run(fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath)); err == nil && config != "" { backupPath := filepath.Join(deviceDir, "SoundTouchSdkPrivateCfg.xml.bak") if err := os.WriteFile(backupPath, []byte(config), 0644); err != nil { return fmt.Errorf("failed to write config backup: %w", err) } } // 2. Backup /etc/hosts if hosts, err := client.Run("cat /etc/hosts"); err == nil && hosts != "" { backupPath := filepath.Join(deviceDir, "hosts.bak") if err := os.WriteFile(backupPath, []byte(hosts), 0644); err != nil { return fmt.Errorf("failed to write hosts backup: %w", err) } } return nil } // BackupConfig creates a backup of the current configuration on the speaker. func (m *Manager) BackupConfig(deviceIP string) (string, error) { client := m.NewSSH(deviceIP) remotePath := SoundTouchSdkPrivateCfgPath rwCmd := "(rw || mount -o remount,rw /)" // Check if .original already exists if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err == nil { return "", fmt.Errorf("backup already exists at %s.original", remotePath) } // Try to copy on the device first (more reliable), ensuring filesystem is writable output, cpErr := client.Run(fmt.Sprintf("%s && cp %s %s.original", rwCmd, remotePath, remotePath)) if cpErr == nil { return output, nil } logs := output + "\n" fmt.Printf("Direct cp failed: %v (output: %s), falling back to cat+upload\n", cpErr, output) // Fallback to cat + upload config, err := client.Run(fmt.Sprintf("cat %s", remotePath)) logs += "cat " + remotePath + ": " + config + "\n" if err != nil || config == "" { return logs, fmt.Errorf("failed to read current config: %w", err) } // Ensure rw before upload fallback out, _ := client.Run(rwCmd) logs += rwCmd + ": " + out + "\n" if err := client.UploadContent([]byte(config), remotePath+".original"); err != nil { return logs, fmt.Errorf("failed to upload backup config: %w", err) } logs += "Uploaded backup to " + remotePath + ".original\n" return logs, nil } // EnsureRemoteServices ensures that remote services are enabled on the device. // It tries to create an empty file in one of the known valid locations. func (m *Manager) EnsureRemoteServices(deviceIP string) (string, error) { client := m.NewSSH(deviceIP) rwCmd := "(rw || mount -o remount,rw /)" // Try locations in order of preference locations := []string{ "/etc/remote_services", "/mnt/nv/remote_services", "/tmp/remote_services", } var logs string for _, loc := range locations { // Try to make filesystem writable for each location that might need it // Combining rw && touch ensures it's attempted in the same sequence out, err := client.Run(fmt.Sprintf("%s && touch %s", rwCmd, loc)) logs += fmt.Sprintf("touch %s (with rw): %s\n", loc, out) if err == nil { return logs, nil } // If rw && touch failed, try just touch (e.g. for /tmp which doesn't need rw) out, err = client.Run(fmt.Sprintf("touch %s", loc)) logs += fmt.Sprintf("touch %s: %s\n", loc, out) if err == nil { return logs, nil } } return logs, fmt.Errorf("failed to enable remote services in any of the locations: %v", locations) } // ProbeCABundles returns the live CA bundle and the .original factory backup // from the speaker at deviceIP via a single SSH round-trip. sshOK is false // when SSH is unreachable or authentication failed; in that case both bundle // strings are empty. Either bundle string may be empty if the corresponding // file does not exist on the device (e.g. .original is absent on speakers // that have never had the AfterTouch CA injected). // // Used by the health check to verify bundle integrity without exposing the // unexported speakerProbe type. func (m *Manager) ProbeCABundles(deviceIP string) (current, original string, sshOK bool) { probe := m.probeSpeakerSSH(deviceIP) return probe.Files["/etc/pki/tls/certs/ca-bundle.crt"], probe.Files["/etc/pki/tls/certs/ca-bundle.crt.original"], probe.SSHOK } // RestoreCABundleFromOriginal copies the .original factory backup over the // live ca-bundle.crt on the speaker. It is called by the health-check fix // executor when check (1) — "original certs are contained in current bundle" // — fails, before re-injecting the AfterTouch CA via TrustCACert. // // The operation requires SSH access and a read-write root filesystem; both // are attempted via the same remount path as TrustCACertFromBytes. func (m *Manager) RestoreCABundleFromOriginal(deviceIP string) error { if m.NewSSH == nil { return errors.New("RestoreCABundleFromOriginal: no SSH factory configured") } bundlePath := "/etc/pki/tls/certs/ca-bundle.crt" client := m.NewSSH(deviceIP) if _, err := client.Run("(rw || mount -o remount,rw /)"); err != nil { return fmt.Errorf("remount rw: %w", err) } if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", bundlePath)); err != nil { return fmt.Errorf("original backup %s.original not found on speaker — run `setup install-ca` first", bundlePath) } if _, err := client.Run(fmt.Sprintf("cp %s.original %s", bundlePath, bundlePath)); err != nil { return fmt.Errorf("restore from original: %w", err) } return nil } // TrustCACert injects the local CA certificate into the device's shared // trust store. The cert is read from disk via Manager.Crypto — used by // the in-process migration flow where the CLI and the certmanager share // a filesystem. Remote/CLI callers without Crypto should fetch the cert // over HTTP and use TrustCACertFromBytes instead. func (m *Manager) TrustCACert(deviceIP string) (string, error) { if m.Crypto == nil { return "", errors.New("TrustCACert: Manager.Crypto is nil — remote callers should fetch the CA via /setup/ca.crt and call TrustCACertFromBytes (e.g. `soundtouch-cli setup install-ca`)") } caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath()) if err != nil { return "", fmt.Errorf("failed to read CA certificate: %w", err) } return m.TrustCACertFromBytes(deviceIP, caCertPEM) } // TrustCACertFromBytes injects the supplied PEM-encoded CA bundle into // the speaker's shared trust store. Identical to TrustCACert except the // cert bytes come from the caller — used by the remote CLI which fetches // /setup/ca.crt over HTTP and never touches Manager.Crypto. // // The write path is two-phase to keep the live bundle never half-written: // // 1. Upload the modified bundle to .aftertouch.tmp (a sibling // on the same filesystem, so the same rw remount covers it). // 2. Read the tmp back over SSH, validate that every PEM block parses // and that the AfterTouch CA sentinel brackets exactly one certificate, // then atomically rename the tmp into place via `mv`. On any failure // between steps 1 and 2 the tmp is unlinked and the live bundle is // untouched — there is no rollback semantics to reason about. // // The .original backup written on first install is retained as // defense-in-depth (a user can manually restore from it if anything outside // this code path corrupts the live bundle), but it is no longer the // primary safety net for our own writes. See issue #262 for the original // failure-mode reporter. func (m *Manager) TrustCACertFromBytes(deviceIP string, caCertPEM []byte) (string, error) { if !strings.Contains(string(caCertPEM), "BEGIN CERTIFICATE") { return "", fmt.Errorf("CA payload does not contain a PEM certificate") } client := m.NewSSH(deviceIP) rwCmd := "(rw || mount -o remount,rw /)" var logs string bundlePath := "/etc/pki/tls/certs/ca-bundle.crt" tmpPath := bundlePath + ".aftertouch.tmp" out, _ := client.Run(rwCmd) logs += rwCmd + ": " + out + "\n" // Backup bundle if it doesn't exist if _, backupErr := client.Run(fmt.Sprintf("[ -f %s.original ]", bundlePath)); backupErr != nil { out, _ := client.Run(fmt.Sprintf("cp %s %s.original", bundlePath, bundlePath)) logs += fmt.Sprintf("cp %s %s.original: %s\n", bundlePath, bundlePath, out) } // Check if the label already exists in the bundle bundleContent, err := client.Run(fmt.Sprintf("cat %s", bundlePath)) logs += "cat " + bundlePath + " (check existing)\n" if err != nil { return logs, fmt.Errorf("failed to read bundle: %w", err) } if strings.Contains(bundleContent, CALabel) { // Rebuild the bundle without our previously-injected CA so the // fresh one replaces the old. Older AfterTouch releases are // reported to have appended the CA on every install without // stripping the previous one, so live bundles can carry // several stale copies — stripAfterTouchEntries collapses // them all and reports the count so we can log a single line // of cleanup rather than failing validation. stripped := stripAfterTouchEntries(bundleContent) bundleContent = stripped.CleanedBundle if stripped.RemovedEntries > 1 { logs += fmt.Sprintf("Cleaned up %d duplicate AfterTouch CA entries from existing bundle\n", stripped.RemovedEntries) } if stripped.UnpairedSentinel { logs += "Warning: existing bundle had an unpaired AfterTouch sentinel; content after it was dropped along with the orphan. If anything legitimate was after the sentinel, restore from " + bundlePath + ".original.\n" } } else if bundleContent != "" && !strings.HasSuffix(bundleContent, "\n") { bundleContent += "\n" } labeledCert := fmt.Sprintf("\n%s\n%s%s\n", CALabel, string(caCertPEM), CALabel) newBundleContent := bundleContent + labeledCert // Pre-upload validation: catch construction-time bugs (mangled PEM, // missing sentinel, etc.) before any SSH write. The live bundle is // untouched at this point. if _, vErr := validateCABundleBytes([]byte(newBundleContent)); vErr != nil { return logs, fmt.Errorf("constructed bundle failed validation, live bundle untouched: %w", vErr) } if vErr := validateAfterTouchLabelBracketing([]byte(newBundleContent)); vErr != nil { return logs, fmt.Errorf("constructed bundle has malformed AfterTouch sentinel, live bundle untouched: %w", vErr) } // Phase 1: upload to a sibling tmp file on the same filesystem. if err := client.UploadContent([]byte(newBundleContent), tmpPath); err != nil { return logs, fmt.Errorf("failed to upload bundle to %s: %w", tmpPath, err) } logs += "Uploaded candidate bundle to " + tmpPath + "\n" // Phase 2: read the tmp back and verify the bytes survived transport. // On any failure here, unlink the tmp; the live bundle was never // touched, so no rollback is required. verifyContent, verifyErr := client.Run(fmt.Sprintf("cat %s", tmpPath)) if verifyErr != nil { _, _ = client.Run(fmt.Sprintf("rm -f %s", tmpPath)) return logs, fmt.Errorf("failed to read back candidate bundle %s for verification, live bundle untouched: %w", tmpPath, verifyErr) } if _, vErr := validateCABundleBytes([]byte(verifyContent)); vErr != nil { _, _ = client.Run(fmt.Sprintf("rm -f %s", tmpPath)) return logs, fmt.Errorf("verification of %s failed (post-upload PEM parse), live bundle untouched: %w", tmpPath, vErr) } if vErr := validateAfterTouchLabelBracketing([]byte(verifyContent)); vErr != nil { _, _ = client.Run(fmt.Sprintf("rm -f %s", tmpPath)) return logs, fmt.Errorf("verification of %s failed (post-upload sentinel bracketing), live bundle untouched: %w", tmpPath, vErr) } logs += "Verified candidate bundle at " + tmpPath + "\n" // Atomic replace. On the device's local filesystem this is a // rename(2) — observers see either the pre- or post-bundle, never // a half-written one. mvCmd := fmt.Sprintf("mv %s %s", tmpPath, bundlePath) if mvOut, mvErr := client.Run(mvCmd); mvErr != nil { _, _ = client.Run(fmt.Sprintf("rm -f %s", tmpPath)) return logs, fmt.Errorf("failed to atomically replace bundle (%s -> %s, output=%q): %w", tmpPath, bundlePath, mvOut, mvErr) } logs += mvCmd + "\n" return logs, nil } func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) { client := m.NewSSH(deviceIP) rwCmd := "(rw || mount -o remount,rw /)" var logs string // 1. Parse targetURL to get IP for /etc/hosts parsedURL, err := url.Parse(targetURL) if err != nil { return "", fmt.Errorf("failed to parse target URL: %w", err) } hostName := parsedURL.Hostname() if hostName == "" || hostName == "localhost" { // Use a better guess if needed, but for now expect valid IP/hostname return "", fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName) } hostIP, err := m.resolveIP(hostName, client) if err != nil { return logs, fmt.Errorf("cannot resolve target hostname for migration: %w", err) } logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP) // 2. Prepare /etc/hosts entries domains := []string{ "streaming.bose.com", "updates.bose.com", "stats.bose.com", "bmx.bose.com", "content.api.bose.io", "events.api.bosecm.com", // app_key validation for /speaker audio notifications (TTS). Without // these redirects the speaker validates against the dead Bose cloud and // reports an invalid app key. Both the prod and dev hosts are seeded // (firmware may use either). DNS interception already covers them via // the bosecm.com substring; seed here for /etc/hosts migrations too. "audionotification.api.bosecm.com", "audionotificationdev.api.bosecm.com", "bose-prod.apigee.net", "worldwide.bose.com", "media.bose.io", "downloads.bose.com", "voice.api.bose.io", } hostsContent, err := client.Run("cat /etc/hosts") logs += "cat /etc/hosts: " + hostsContent + "\n" if err != nil { return logs, fmt.Errorf("failed to read /etc/hosts: %w", err) } hostsContent = m.generateHostsContent(hostsContent, domains, hostIP) // 3. Upload new /etc/hosts out, _ := client.Run(rwCmd) logs += rwCmd + ": " + out + "\n" // Backup /etc/hosts if it doesn't exist if _, err := client.Run("[ -f /etc/hosts.original ]"); err != nil { out, _ := client.Run("cp /etc/hosts /etc/hosts.original") logs += "cp /etc/hosts /etc/hosts.original: " + out + "\n" } if err := client.UploadContent([]byte(hostsContent), "/etc/hosts"); err != nil { return logs, fmt.Errorf("failed to update /etc/hosts: %w", err) } logs += "Uploaded updated /etc/hosts\n" // 4. Verify /etc/hosts on device if err := m.verifyHosts(client, domains, hostIP, deviceIP); err != nil { return logs, err } logs += "Verified /etc/hosts on device\n" fmt.Printf("Updated /etc/hosts on %s:\n%s\n", sanitizeLog(deviceIP), sanitizeLog(hostsContent)) // 5. Inject CA Certificate summary := &MigrationSummary{} m.checkCACertTrusted(summary, deviceIP) if !summary.CACertTrusted { out, err := m.TrustCACert(deviceIP) logs += "Trusting CA:\n" + out + "\n" if err != nil { return logs, err } } else { logs += "CA certificate already trusted, skipping injection\n" fmt.Printf("CA certificate already trusted on %s, skipping injection\n", deviceIP) } return logs, nil } func (m *Manager) generateHostsContent(currentContent string, domains []string, hostIP string) string { lines := strings.Split(currentContent, "\n") var newLines []string domainFound := make(map[string]bool) for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "#") { newLines = append(newLines, line) continue } fields := strings.Fields(trimmed) if len(fields) >= 2 { domain := fields[1] isBoseDomain := false for _, d := range domains { if d == domain { isBoseDomain = true break } } if isBoseDomain { // Update existing entry with new IP newLines = append(newLines, fmt.Sprintf("%s\t%s", hostIP, domain)) domainFound[domain] = true continue } } newLines = append(newLines, line) } // Add missing domains for _, domain := range domains { if !domainFound[domain] { newLines = append(newLines, fmt.Sprintf("%s\t%s", hostIP, domain)) } } hostsContent := strings.Join(newLines, "\n") if !strings.HasSuffix(hostsContent, "\n") { hostsContent += "\n" } return hostsContent } func (m *Manager) verifyHosts(client SSHClient, domains []string, hostIP, deviceIP string) error { verification, err := client.Run("cat /etc/hosts") if err != nil { return fmt.Errorf("could not verify /etc/hosts on device: %w", err) } for _, domain := range domains { if !strings.Contains(verification, domain) || !strings.Contains(verification, hostIP) { return fmt.Errorf("verification failed: /etc/hosts on %s does not contain expected redirection for %s", deviceIP, domain) } } return nil } func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, error) { client := m.NewSSH(deviceIP) rwCmd := "(rw || mount -o remount,rw /)" var logs string // 1. Resolve target hostname to IP parsedURL, err := url.Parse(targetURL) if err != nil { return "", fmt.Errorf("failed to parse target URL: %w", err) } hostName := parsedURL.Hostname() if hostName == "" || hostName == "localhost" { return "", fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName) } hostIP, err := m.resolveIP(hostName, client) if err != nil { return logs, fmt.Errorf("cannot resolve target hostname for migration: %w", err) } logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP) // 2. Prepare /mnt/nv/soundtouch-service/aftertouch.resolv.conf content resolvContent := fmt.Sprintf("# Created by Aftertouch/SoundTouch-Service\n# Priority nameserver for Bose service redirection\nnameserver %s\n", hostIP) // 3. Upload /mnt/nv/soundtouch-service/aftertouch.resolv.conf // Ensure /mnt/nv/soundtouch-service exists _, _ = client.Run("mkdir -p /mnt/nv/soundtouch-service") if uploadErr := client.UploadContent([]byte(resolvContent), "/mnt/nv/soundtouch-service/aftertouch.resolv.conf"); uploadErr != nil { return logs, fmt.Errorf("failed to upload /mnt/nv/soundtouch-service/aftertouch.resolv.conf: %w", uploadErr) } logs += "Uploaded /mnt/nv/soundtouch-service/aftertouch.resolv.conf\n" // 4. Update /mnt/nv/rc.local with idempotent patch patchOut, err := m.updateRcLocalWithDNSHook(client) logs += patchOut if err != nil { return logs, err } // 5. Cleanup legacy file _, _ = client.Run("rm -f /mnt/nv/aftertouch.resolv.conf") // 6. Apply patch immediately to /etc/udhcpc.d/50default rwOut, _ := client.Run(rwCmd) logs += rwCmd + ": " + rwOut + "\n" hookMarker := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf" targetDHCPFile := "/etc/udhcpc.d/50default" dhcpPatchOut, err := m.patchDHCPFile(client, targetDHCPFile, hookMarker) logs += dhcpPatchOut if err != nil { logs += fmt.Sprintf("Warning: could not apply/verify patch on %s: %v\n", targetDHCPFile, err) } // Apply patch immediately to /opt/Bose/udhcpc.script if it exists targetScript := "/opt/Bose/udhcpc.script" if _, err := client.Run(fmt.Sprintf("[ -f %s ]", targetScript)); err == nil { scriptPatchOut, err := m.patchUdhcpcScript(client, targetScript, hookMarker) logs += scriptPatchOut if err != nil { logs += fmt.Sprintf("Warning: could not apply/verify patch on %s: %v\n", targetScript, err) } } // 6. Inject CA Certificate summary := &MigrationSummary{} m.checkCACertTrusted(summary, deviceIP) if !summary.CACertTrusted { out, err := m.TrustCACert(deviceIP) logs += "Trusting CA:\n" + out + "\n" if err != nil { return logs, err } } else { logs += "CA certificate already trusted, skipping injection\n" } return logs, nil } func (m *Manager) updateRcLocalWithDNSHook(client SSHClient) (string, error) { var logs string rcLocalPath := "/mnt/nv/rc.local" targetDHCPFile := "/etc/udhcpc.d/50default" hookMarker := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf" // Check if rc.local exists and read it currentRcLocal, rcErr := client.Run(fmt.Sprintf("cat %s", rcLocalPath)) if rcErr != nil { currentRcLocal = "" } if strings.Contains(currentRcLocal, hookMarker) { return fmt.Sprintf("%s already contains Aftertouch hook logic\n", rcLocalPath), nil } patchStartMarker := "# --- Aftertouch DNS hook START ---" patchEndMarker := "# --- Aftertouch DNS hook END ---" patchLogic := fmt.Sprintf(` %s # prioritizes our custom nameserver if it exists if [ -f "%s" ]; then if [ -f "%s" ] && ! grep -q "%s" "%s"; then logger -t "aftertouch" "Patching %s with Aftertouch DNS hook" sed -i '/echo "search \$domain"/a \ [ -f '"%s"' ] && cat '"%s"' && dns=""' "%s" fi targetScript="/opt/Bose/udhcpc.script" if [ -f "$targetScript" ] && ! grep -q "%s" "$targetScript"; then logger -t "aftertouch" "Patching $targetScript with Aftertouch DNS hook" sed -i '/echo "search \$search_list # \$interface" >> \$RESOLV_CONF/a \ [ -f '"%s"' ] && cat '"%s"' >> '"\$RESOLV_CONF"' && dns=""' "$targetScript" fi fi %s `, patchStartMarker, hookMarker, targetDHCPFile, hookMarker, targetDHCPFile, targetDHCPFile, hookMarker, hookMarker, targetDHCPFile, hookMarker, hookMarker, hookMarker, patchEndMarker) newRcLocal := currentRcLocal // Remove old-style DNS hook if it exists if strings.Contains(newRcLocal, "# Aftertouch DNS hook") && !strings.Contains(newRcLocal, patchStartMarker) { // Old removal: filter out lines between the marker and the first 'fi' lines := strings.Split(newRcLocal, "\n") var filteredLines []string skip := false for _, line := range lines { if strings.Contains(line, "# Aftertouch DNS hook") { skip = true continue } if skip && strings.TrimSpace(line) == "fi" { skip = false continue } if !skip { filteredLines = append(filteredLines, line) } } newRcLocal = strings.Join(filteredLines, "\n") } // Remove existing marker-based hook if it exists (for update) if strings.Contains(newRcLocal, patchStartMarker) { startIdx := strings.Index(newRcLocal, patchStartMarker) endIdx := strings.Index(newRcLocal, patchEndMarker) if startIdx != -1 && endIdx != -1 { newRcLocal = newRcLocal[:startIdx] + newRcLocal[endIdx+len(patchEndMarker):] } } // Remove "cat: can't open..." error message if it was accidentally saved in the file if strings.Contains(newRcLocal, "cat: can't open") { newRcLocal = "" } if !strings.HasPrefix(newRcLocal, "#!/bin/sh") { newRcLocal = "#!/bin/sh\n" + strings.TrimPrefix(newRcLocal, "#!/bin/sh") } if !strings.HasSuffix(newRcLocal, "\n") { newRcLocal += "\n" } newRcLocal += patchLogic if err := client.UploadContent([]byte(newRcLocal), rcLocalPath); err != nil { return logs, fmt.Errorf("failed to update %s: %w", rcLocalPath, err) } logs += fmt.Sprintf("Updated %s with DNS hook logic\n", rcLocalPath) // Make it executable _, _ = client.Run(fmt.Sprintf("chmod +x %s", rcLocalPath)) return logs, nil } func (m *Manager) patchDHCPFile(client SSHClient, targetDHCPFile, hookMarker string) (string, error) { var logs string // Backup if it doesn't exist if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetDHCPFile)); err != nil { out, _ := client.Run(fmt.Sprintf("cp %s %s.original", targetDHCPFile, targetDHCPFile)) logs += fmt.Sprintf("cp %s %s.original: %s\n", targetDHCPFile, targetDHCPFile, out) } else { // If backup exists, revert to it first to ensure we start from a clean state _, _ = client.Run(fmt.Sprintf("cp %s.original %s", targetDHCPFile, targetDHCPFile)) } // Run the patch logic via SSH to apply it now patchCmd := fmt.Sprintf("sed -i '/echo \"search \\$domain\"/a \\ [ -f '\"%s\"' ] && cat '\"%s\"' && dns=\"\"' %s", hookMarker, hookMarker, targetDHCPFile) if _, err := client.Run(patchCmd); err != nil { return logs, fmt.Errorf("failed to apply patch immediately to %s: %w", targetDHCPFile, err) } logs += fmt.Sprintf("Applied patch to %s\n", targetDHCPFile) // Verify patch on 50default if verification, err := client.Run(fmt.Sprintf("grep -q \"%s\" %s && echo \"OK\"", hookMarker, targetDHCPFile)); err == nil && strings.TrimSpace(verification) == "OK" { logs += fmt.Sprintf("Verified patch on %s\n", targetDHCPFile) } else { return logs, fmt.Errorf("could not verify patch on %s: %w", targetDHCPFile, err) } return logs, nil } func (m *Manager) patchUdhcpcScript(client SSHClient, targetScript, hookMarker string) (string, error) { var logs string // Backup if it doesn't exist if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetScript)); err != nil { out, _ := client.Run(fmt.Sprintf("cp %s %s.original", targetScript, targetScript)) logs += fmt.Sprintf("cp %s %s.original: %s\n", targetScript, targetScript, out) } else { // If backup exists, revert to it first to ensure we start from a clean state _, _ = client.Run(fmt.Sprintf("cp %s.original %s", targetScript, targetScript)) } patchCmdScript := fmt.Sprintf("sed -i '/echo \"search \\$search_list # \\$interface\" >> \\$RESOLV_CONF/a \\ [ -f '\"%s\"' ] && cat '\"%s\"' >> '\"\\$RESOLV_CONF\"' && dns=\"\"' %s", hookMarker, hookMarker, targetScript) if _, err := client.Run(patchCmdScript); err != nil { return logs, fmt.Errorf("failed to apply patch immediately to %s: %w", targetScript, err) } logs += fmt.Sprintf("Applied patch to %s\n", targetScript) // Verify patch on udhcpc.script if verification, err := client.Run(fmt.Sprintf("grep -q \"%s\" %s && echo \"OK\"", hookMarker, targetScript)); err == nil && strings.TrimSpace(verification) == "OK" { logs += fmt.Sprintf("Verified patch on %s\n", targetScript) } else { return logs, fmt.Errorf("could not verify patch on %s: %w", targetScript, err) } return logs, nil } // RevertMigration reverts the speaker to its original Bose cloud configuration. func (m *Manager) RevertMigration(deviceIP string) (string, error) { client := m.NewSSH(deviceIP) // This function alone makes ~17 client.Run/UploadContent calls across // its sub-steps below. Dialing a fresh SSH connection per call (the // default when Connect isn't used) was confirmed on real hardware to // overwhelm a resource-constrained speaker; Connect+defer Close keeps // it to one connection for the whole revert instead. if err := client.Connect(); err != nil { return "", fmt.Errorf("failed to connect for revert: %w", err) } defer func() { _ = client.Close() }() rwCmd := "(rw || mount -o remount,rw /)" var logs string // 1. Revert SoundTouchSdkPrivateCfg.xml out, err := m.revertXMLConfig(client, rwCmd) logs += out if err != nil { return logs, err } // 2. Revert /etc/hosts logs += m.revertHosts(client, rwCmd) // 2b. Revert /etc/resolv.conf logs += m.revertResolvConf(client, rwCmd) // 2c. Revert Aftertouch DNS Hook logs += m.revertAftertouchHook(client, rwCmd) // 3. Remove CA certificate from trust store if it exists logs += m.revertCACert(client, rwCmd) return logs, nil } func (m *Manager) revertXMLConfig(client SSHClient, rwCmd string) (string, error) { var logs string remotePath := SoundTouchSdkPrivateCfgPath if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err == nil { logs += fmt.Sprintf("Reverting %s from backup\n", remotePath) fmt.Printf("Reverting %s from backup\n", remotePath) out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, remotePath, remotePath)) logs += fmt.Sprintf("cp %s.original %s: %s\n", remotePath, remotePath, out) if err != nil { return logs, fmt.Errorf("failed to revert %s: %w", remotePath, err) } } else { return logs, fmt.Errorf("backup %s.original not found, cannot revert", remotePath) } return logs, nil } func (m *Manager) revertHosts(client SSHClient, rwCmd string) string { var logs string hostsPath := "/etc/hosts" if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", hostsPath)); err == nil { logs += fmt.Sprintf("Reverting %s from backup\n", hostsPath) fmt.Printf("Reverting %s from backup\n", hostsPath) out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, hostsPath, hostsPath)) logs += fmt.Sprintf("cp %s.original %s: %s\n", hostsPath, hostsPath, out) if err != nil { fmt.Printf("Warning: failed to revert %s: %v\n", hostsPath, err) } } return logs } func (m *Manager) revertResolvConf(client SSHClient, rwCmd string) string { var logs string resolvPath := "/etc/resolv.conf" if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", resolvPath)); err == nil { logs += fmt.Sprintf("Reverting %s from backup\n", resolvPath) fmt.Printf("Reverting %s from backup\n", resolvPath) // Try to remove immutable flag if it was set _, _ = client.Run(fmt.Sprintf("chattr -i %s", resolvPath)) out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, resolvPath, resolvPath)) logs += fmt.Sprintf("cp %s.original %s: %s\n", resolvPath, resolvPath, out) if err != nil { fmt.Printf("Warning: failed to revert %s: %v\n", resolvPath, err) } } return logs } func (m *Manager) revertAftertouchHook(client SSHClient, rwCmd string) string { var logs string aftertouchConfPath := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf" legacyConfPath := "/mnt/nv/aftertouch.resolv.conf" rcLocalPath := "/mnt/nv/rc.local" targetDHCPFile := "/etc/udhcpc.d/50default" for _, p := range []string{aftertouchConfPath, legacyConfPath} { if _, err := client.Run(fmt.Sprintf("[ -f %s ]", p)); err == nil { logs += fmt.Sprintf("Removing %s\n", p) fmt.Printf("Removing %s\n", p) _, _ = client.Run(fmt.Sprintf("rm %s", p)) } } logs += m.removeRcLocalHooks(client, rcLocalPath) if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetDHCPFile)); err == nil { logs += fmt.Sprintf("Reverting %s from backup\n", targetDHCPFile) fmt.Printf("Reverting %s from backup\n", targetDHCPFile) out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, targetDHCPFile, targetDHCPFile)) logs += fmt.Sprintf("cp %s.original %s: %s\n", targetDHCPFile, targetDHCPFile, out) if err != nil { fmt.Printf("Warning: failed to revert %s: %v\n", targetDHCPFile, err) } } targetScript := "/opt/Bose/udhcpc.script" if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetScript)); err == nil { logs += fmt.Sprintf("Reverting %s from backup\n", targetScript) fmt.Printf("Reverting %s from backup\n", targetScript) out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, targetScript, targetScript)) logs += fmt.Sprintf("cp %s.original %s: %s\n", targetScript, targetScript, out) if err != nil { fmt.Printf("Warning: failed to revert %s: %v\n", targetScript, err) } } return logs } func (m *Manager) removeRcLocalHooks(client SSHClient, rcLocalPath string) string { var logs string patchStartMarker := "# --- Aftertouch DNS hook START ---" patchEndMarker := "# --- Aftertouch DNS hook END ---" spotifyPatchStartMarker := "# --- Aftertouch Spotify hook START ---" spotifyPatchEndMarker := "# --- Aftertouch Spotify hook END ---" aftertouchConfPath := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf" legacyAftertouchConfPath := "/mnt/nv/aftertouch.resolv.conf" currentRcLocal, err := client.Run(fmt.Sprintf("cat %s", rcLocalPath)) if err != nil { return "" } // Remove "cat: can't open..." error message if it was accidentally saved in the file if strings.Contains(currentRcLocal, "cat: can't open") { logs += fmt.Sprintf("Removing corrupted %s\n", rcLocalPath) _, _ = client.Run(fmt.Sprintf("rm %s", rcLocalPath)) return logs } modified := false if strings.Contains(currentRcLocal, patchStartMarker) { logs += fmt.Sprintf("Removing Aftertouch hook logic from %s\n", rcLocalPath) fmt.Printf("Removing Aftertouch hook logic from %s\n", rcLocalPath) startIdx := strings.Index(currentRcLocal, patchStartMarker) endIdx := strings.Index(currentRcLocal, patchEndMarker) if startIdx != -1 && endIdx != -1 { currentRcLocal = currentRcLocal[:startIdx] + currentRcLocal[endIdx+len(patchEndMarker):] modified = true } } else if strings.Contains(currentRcLocal, aftertouchConfPath) || strings.Contains(currentRcLocal, legacyAftertouchConfPath) || strings.Contains(currentRcLocal, "# Aftertouch DNS hook") { logs += fmt.Sprintf("Removing legacy Aftertouch hook logic from %s\n", rcLocalPath) fmt.Printf("Removing legacy Aftertouch hook logic from %s\n", rcLocalPath) lines := strings.Split(currentRcLocal, "\n") var newLines []string skip := false for _, line := range lines { if strings.Contains(line, "# Aftertouch DNS hook") { skip = true continue } if skip && strings.TrimSpace(line) == "fi" { skip = false continue } if !skip { newLines = append(newLines, line) } } currentRcLocal = strings.Join(newLines, "\n") modified = true } if strings.Contains(currentRcLocal, spotifyPatchStartMarker) { logs += fmt.Sprintf("Removing Spotify hook logic from %s\n", rcLocalPath) fmt.Printf("Removing Spotify hook logic from %s\n", rcLocalPath) startIdx := strings.Index(currentRcLocal, spotifyPatchStartMarker) endIdx := strings.Index(currentRcLocal, spotifyPatchEndMarker) if startIdx != -1 && endIdx != -1 { currentRcLocal = currentRcLocal[:startIdx] + currentRcLocal[endIdx+len(spotifyPatchEndMarker):] modified = true } } if modified { if err := client.UploadContent([]byte(currentRcLocal), rcLocalPath); err != nil { fmt.Printf("Warning: failed to update %s: %v\n", rcLocalPath, err) } } return logs } func (m *Manager) revertCACert(client SSHClient, rwCmd string) string { var logs string bundlePath := "/etc/pki/tls/certs/ca-bundle.crt" if bundleContent, err := client.Run(fmt.Sprintf("cat %s", bundlePath)); err == nil && strings.Contains(bundleContent, CALabel) { logs += fmt.Sprintf("Removing local CA certificate from %s\n", bundlePath) fmt.Printf("Removing local CA certificate from %s\n", bundlePath) lines := strings.Split(bundleContent, "\n") var newLines []string inOurCA := false for _, line := range lines { if strings.Contains(line, CALabel) { inOurCA = !inOurCA continue } if !inOurCA { newLines = append(newLines, line) } } bundleContent = strings.Join(newLines, "\n") if bundleContent != "" && !strings.HasSuffix(bundleContent, "\n") { bundleContent += "\n" } out, _ := client.Run(rwCmd) logs += rwCmd + ": " + out + "\n" if err := client.UploadContent([]byte(bundleContent), bundlePath); err != nil { logs += "Warning: failed to remove CA from " + bundlePath + ": " + err.Error() + "\n" fmt.Printf("Warning: failed to remove CA from %s: %v\n", bundlePath, err) } else { logs += "Uploaded updated bundle (CA removed)\n" } } return logs } // RemoveRemoteServices removes remote services from the device by deleting the known remote_services files. func (m *Manager) RemoveRemoteServices(deviceIP string) (string, error) { client := m.NewSSH(deviceIP) rwCmd := "(rw || mount -o remount,rw /)" locations := []string{ "/etc/remote_services", "/mnt/nv/remote_services", "/tmp/remote_services", } var ( logs string errors []error ) for _, loc := range locations { // Try to make filesystem writable and remove the file out, err := client.Run(fmt.Sprintf("%s && rm -v %s", rwCmd, loc)) logs += fmt.Sprintf("Removing %s: %s\n", loc, out) if err != nil { // If rw && rm failed, try just rm (e.g. for /tmp) out, err = client.Run(fmt.Sprintf("rm -v %s", loc)) logs += fmt.Sprintf("Fallback removing %s: %s\n", loc, out) if err != nil { errors = append(errors, fmt.Errorf("failed to remove %s: %w", loc, err)) } } } if len(errors) == len(locations) { return logs, fmt.Errorf("failed to remove remote services from any location: %v", errors) } return logs, nil } // RebootMethod selects the transport used to reboot a speaker. type RebootMethod string const ( // RebootMethodSSH reboots via SSH `reboot` (the original behavior). Requires // a rooted device (remote_services unlocked). RebootMethodSSH RebootMethod = "ssh" // RebootMethodTelnet reboots via the device's port-17000 diagnostic shell // using `sys reboot`. Requires no SSH access. RebootMethodTelnet RebootMethod = "telnet" ) // Reboot reboots the speaker at the given IP using the requested transport. // An empty method defaults to RebootMethodSSH, preserving prior behavior. func (m *Manager) Reboot(deviceIP string, method RebootMethod) (string, error) { if method == "" { method = RebootMethodSSH } switch method { case RebootMethodSSH: return m.rebootViaSSH(deviceIP) case RebootMethodTelnet: return m.rebootViaTelnet(deviceIP) default: return "", fmt.Errorf("unsupported reboot method: %s", method) } } func (m *Manager) rebootViaSSH(deviceIP string) (string, error) { client := m.NewSSH(deviceIP) rwCmd := "(rw || mount -o remount,rw /)" fmt.Printf("Rebooting speaker at %s via SSH\n", deviceIP) out, err := client.Run(fmt.Sprintf("%s && reboot", rwCmd)) if err != nil { return out, fmt.Errorf("failed to reboot speaker: %w", err) } return out, nil } func (m *Manager) rebootViaTelnet(deviceIP string) (string, error) { if m.NewTelnet == nil { return "", errors.New("telnet reboot not configured: Manager.NewTelnet is nil") } fmt.Printf("Rebooting speaker at %s via telnet\n", deviceIP) t := m.NewTelnet(deviceIP) if err := t.Dial(); err != nil { return "", fmt.Errorf("telnet dial %s:17000 failed: %w", deviceIP, err) } defer func() { _ = t.Close() }() // We deliberately don't wait for a response — the device closes the socket // as part of rebooting, and SendCommand would surface that as an error // even though the reboot itself succeeded. Treat any short read or close // as "command was accepted". resp, err := t.SendCommand("sys reboot") if err != nil { // A read error after the write is the expected case (socket dies on // reboot). Only surface real transport failures; treat the rest as // success and let the caller verify by polling :8090/info. if isLikelyRebootCloseError(err) { return resp + "\n[connection closed by reboot]", nil } return resp, fmt.Errorf("failed to send sys reboot: %w", err) } return resp, nil } // isLikelyRebootCloseError returns true if err looks like the socket closed // because the device started rebooting, rather than a real connectivity // problem. We are intentionally generous here: the user already opted into // rebooting, so a closed socket is expected. func isLikelyRebootCloseError(err error) bool { msg := err.Error() for _, marker := range []string{"EOF", "closed", "connection reset", "broken pipe", "timed out"} { if strings.Contains(msg, marker) { return true } } return false } // TestDomain is the fake domain used for preliminary redirection tests. const TestDomain = "custom-test-api.bose.fake" // CALabel is the label used to identify the local CA certificate in the trust store. const CALabel = "# AfterTouch" // TestHostsRedirection performs a preliminary check to see if /etc/hosts redirection works. func (m *Manager) TestHostsRedirection(deviceIP, targetURL string) (string, error) { client := m.NewSSH(deviceIP) rwCmd := "(rw || mount -o remount,rw /)" hostIP, parsedURL, err := m.parseTargetURLAndResolveIP(targetURL, client) if err != nil { return "", err } testDomain := TestDomain testEntry := fmt.Sprintf("%s\t%s", hostIP, testDomain) if addErr := m.addTemporaryHostEntry(client, deviceIP, testDomain, testEntry, rwCmd); addErr != nil { return "", addErr } defer m.cleanupTemporaryHostEntry(client, testDomain, rwCmd) output, err := m.runHTTPRedirectionTest(client, parsedURL, testDomain) if err != nil { return output, err } httpsOutput, httpsErr := m.runHTTPSRedirectionTest(client, testDomain) combinedOutput := output + "\n---\n" + httpsOutput if httpsErr != nil { return combinedOutput, fmt.Errorf("hosts redirection HTTPS test failed: %w", httpsErr) } return combinedOutput, nil } // TestDNSRedirection performs a check from the device to see if DNS queries are intercepted by the AfterTouch service. func (m *Manager) TestDNSRedirection(deviceIP, targetURL string) (string, error) { client := m.NewSSH(deviceIP) hostIP, _, err := m.parseTargetURLAndResolveIP(targetURL, client) if err != nil { return "", err } // Use a raw DNS query via nc (netcat) to test DNS resolution from the device, // because BusyBox nslookup might not support custom ports. testDomain := "aftertouch.test" // Fetch configured DNS port if available dnsPort := "53" if m.DataStore != nil { if dsSettings, getSettingsErr := m.DataStore.GetSettings(); getSettingsErr == nil && dsSettings.DNSBindAddr != "" { if lastColon := strings.LastIndex(dsSettings.DNSBindAddr, ":"); lastColon != -1 { port := dsSettings.DNSBindAddr[lastColon+1:] if _, atoiErr := strconv.Atoi(port); atoiErr == nil { dnsPort = port } } } } // Raw DNS query for aftertouch.test (Type A, Class IN) // Transaction ID: 0xAAAA, Flags: 0x0100 (Standard query), Questions: 1, Answer RRs: 0, Authority RRs: 0, Additional RRs: 0 // Query: aftertouch.test, Type: A, Class: IN // For TCP, we need a 2-byte length prefix: 0x0021 (33 bytes) dnsQueryHex := "\\x00\\x21\\xaa\\xaa\\x01\\x00\\x00\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x0aaftertouch\\x04test\\x00\\x00\\x01\\x00\\x01" // We use TCP (default for nc) because BusyBox nc might not support -u, // and our DNS server listens on both TCP and UDP. // DNS over TCP response also has a 2-byte length prefix, but tail -c 4 will still get the IP from the end. ncCmd := fmt.Sprintf("echo -ne '%s' | nc -w 5 %s %s | tail -c 4 | od -An -tu1", dnsQueryHex, hostIP, dnsPort) output, err := client.Run(ncCmd) if err == nil { // Parse the IP from od output: " 192 168 178 122" fields := strings.Fields(output) if len(fields) == 4 { resolvedIP := fmt.Sprintf("%s.%s.%s.%s", fields[0], fields[1], fields[2], fields[3]) if resolvedIP == hostIP { return fmt.Sprintf("Success: Raw DNS query for %s returned %s via nc to %s:%s", testDomain, resolvedIP, hostIP, dnsPort), nil } return output, fmt.Errorf("DNS redirection test failed: nc returned %s, expected %s", resolvedIP, hostIP) } } // Fallback to nslookup if nc fails (maybe nc is missing or it's standard port 53) serverAddr := hostIP if dnsPort != "53" { serverAddr = fmt.Sprintf("%s:%s", hostIP, dnsPort) } nslookupCmd := fmt.Sprintf("nslookup %s %s", testDomain, serverAddr) nslookupOutput, nslookupErr := client.Run(nslookupCmd) if nslookupErr == nil && strings.Contains(nslookupOutput, hostIP) { return nslookupOutput, nil } return fmt.Sprintf("nc Output: %s (err: %v)\nnslookup Output: %s (err: %v)", output, err, nslookupOutput, nslookupErr), fmt.Errorf("DNS redirection test failed: both nc and nslookup failed to resolve %s", testDomain) } func (m *Manager) parseTargetURLAndResolveIP(targetURL string, client SSHClient) (string, *url.URL, error) { parsedURL, err := url.Parse(targetURL) if err != nil { return "", nil, fmt.Errorf("failed to parse target URL: %w", err) } hostName := parsedURL.Hostname() if hostName == "" || hostName == "localhost" { return "", nil, fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName) } hostIP, err := m.resolveIP(hostName, client) if err != nil { return "", nil, fmt.Errorf("cannot resolve target hostname: %w", err) } return hostIP, parsedURL, nil } func (m *Manager) addTemporaryHostEntry(client SSHClient, deviceIP, testDomain, testEntry, rwCmd string) error { hostsContent, err := client.Run("cat /etc/hosts") if err != nil { return fmt.Errorf("failed to read /etc/hosts: %w", err) } if strings.Contains(hostsContent, testDomain) { lines := strings.Split(hostsContent, "\n") var newLines []string for _, line := range lines { if line != "" && !strings.Contains(line, testDomain) { newLines = append(newLines, line) } } hostsContent = strings.Join(newLines, "\n") if len(newLines) > 0 { hostsContent += "\n" } } _, _ = client.Run(rwCmd) if hostsContent != "" && !strings.HasSuffix(hostsContent, "\n") { hostsContent += "\n" } newHostsContent := hostsContent + testEntry + "\n" if uploadErr := client.UploadContent([]byte(newHostsContent), "/etc/hosts"); uploadErr != nil { return fmt.Errorf("failed to add test entry to /etc/hosts: %w", uploadErr) } fmt.Printf("Updated /etc/hosts on %s with test entry:\n%s\n", sanitizeLog(deviceIP), sanitizeLog(newHostsContent)) return nil } func (m *Manager) cleanupTemporaryHostEntry(client SSHClient, testDomain, rwCmd string) { currentContent, _ := client.Run("cat /etc/hosts") lines := strings.Split(currentContent, "\n") var newLines []string for _, line := range lines { if line != "" && !strings.Contains(line, testDomain) { newLines = append(newLines, line) } } finalContent := strings.Join(newLines, "\n") if len(newLines) > 0 { finalContent += "\n" } _, _ = client.Run(rwCmd) _ = client.UploadContent([]byte(finalContent), "/etc/hosts") } func (m *Manager) runHTTPRedirectionTest(client SSHClient, parsedURL *url.URL, testDomain string) (string, error) { httpTestURL := fmt.Sprintf("http://%s:%s/health", testDomain, parsedURL.Port()) if parsedURL.Port() == "" || parsedURL.Port() == "80" { httpTestURL = fmt.Sprintf("http://%s/health", testDomain) } cmd := fmt.Sprintf("curl --max-time 15 --connect-timeout 10 -v -s -L %s", httpTestURL) output, err := client.Run(cmd) if err != nil { return output, fmt.Errorf("hosts redirection HTTP test failed: %w", err) } return output, nil } func (m *Manager) runHTTPSRedirectionTest(client SSHClient, testDomain string) (string, error) { httpsPort := os.Getenv("HTTPS_PORT") if httpsPort == "" { httpsPort = "8443" } httpsTestURL := fmt.Sprintf("https://%s:%s/health", testDomain, httpsPort) if httpsPort == "443" { httpsTestURL = fmt.Sprintf("https://%s/health", testDomain) } caPEM, err := os.ReadFile(m.Crypto.GetCACertPath()) if err != nil { return "", fmt.Errorf("failed to read CA cert for HTTPS test: %w", err) } caPath := "/tmp/soundtouch-test-ca.crt" if err := client.UploadContent(caPEM, caPath); err != nil { return "", fmt.Errorf("failed to upload temporary CA for HTTPS test: %w", err) } defer func() { _, _ = client.Run("rm " + caPath) }() httpsCmd := fmt.Sprintf("curl --max-time 15 --connect-timeout 10 -v -s -L --cacert %s %s", caPath, httpsTestURL) return client.Run(httpsCmd) } // TestConnection performs a connection check from the device to the server. func (m *Manager) TestConnection(deviceIP, targetURL string, useExplicitCA bool) (string, error) { client := m.NewSSH(deviceIP) caPath := "" if useExplicitCA { // Temporary upload CA to device caPEM, err := os.ReadFile(m.Crypto.GetCACertPath()) if err != nil { return "", fmt.Errorf("failed to read CA cert: %w", err) } caPath = "/tmp/soundtouch-test-ca.crt" if err := client.UploadContent(caPEM, caPath); err != nil { return "", fmt.Errorf("failed to upload temporary CA: %w", err) } defer func() { _, _ = client.Run("rm " + caPath) }() } cmd := fmt.Sprintf("curl --max-time 15 --connect-timeout 10 -v -s -L %s", targetURL) if useExplicitCA { cmd += " --cacert " + caPath } output, err := client.Run(cmd) if err != nil { return output, fmt.Errorf("connection test failed: %w", err) } return output, nil } // GetResolvedIP returns the resolved IP for a hostname, attempting to resolve it from any connected device first. func (m *Manager) GetResolvedIP(host string) string { ip, _ := m.resolveIP(host, nil) return ip } // ErrResolvedFromServiceOnly is returned (wrapped) by resolveIP when the // service-side DNS fallback produced an IP but the device-side SSH ping // either wasn't attempted or didn't yield a usable result. The error // carries the resolved IP — callers that don't need an authoritative // device-side answer (preview/summary builders) can errors.Is()-check // and treat the IP as informational. Apply-path callers that DO need // authoritative resolution can bail. var ErrResolvedFromServiceOnly = errors.New("resolved from service, not from device") // resolveIP resolves a hostname to an IP address. // It first tries to resolve from the device via SSH ping (authoritative for migration). // If that fails, it falls back to resolving from the service itself, returning the // resolved IP wrapped with ErrResolvedFromServiceOnly so callers can distinguish // "authoritative device-side answer" from "best-effort service-side fallback". func (m *Manager) resolveIP(host string, client SSHClient) (string, error) { if net.ParseIP(host) != nil { return host, nil } // 1. Try resolving FROM the device via SSH (authoritative: gives the IP the device will actually use) if client != nil { // Use ping to resolve hostname on the device. // Busybox ping output usually looks like: PING host (1.2.3.4): 56 data bytes output, err := client.Run(fmt.Sprintf("ping -c 1 %s", host)) if err == nil { // Extract IP from parentheses: (1.2.3.4) start := strings.Index(output, "(") end := strings.Index(output, ")") if start != -1 && end > start { ip := output[start+1 : end] if net.ParseIP(ip) != nil { fmt.Printf("Resolved %s to %s from device\n", sanitizeLog(host), sanitizeLog(ip)) return ip, nil } } } } // 2. Fallback: resolve FROM the service itself (unreliable for migration — NAT/split-DNS may differ) ips, err := net.LookupIP(host) if err != nil || len(ips) == 0 { return "", fmt.Errorf("cannot resolve %q: SSH ping from device failed and service-side DNS lookup also failed", host) } // Prefer IPv4 var resolved string for _, ip := range ips { if ip.To4() != nil { resolved = ip.String() break } } if resolved == "" { resolved = ips[0].String() } return resolved, fmt.Errorf("%w: %q → %s (NAT or split-DNS may differ from what the device would see)", ErrResolvedFromServiceOnly, host, resolved) } // SyncDeviceData fetches presets, recents and sources from the device and saves them to the datastore. func (m *Manager) SyncDeviceData(deviceIP string) error { // 1. Fetch info to get Serial Number (account identifier) info, err := m.GetLiveDeviceInfo(deviceIP) if err != nil { return fmt.Errorf("failed to get device info: %w", err) } log.Printf("Starting sync for device at %s: Name='%s', DeviceID='%s', SerialNumber='%s'", sanitizeLog(deviceIP), sanitizeLog(info.Name), sanitizeLog(info.DeviceID), sanitizeLog(info.SerialNumber)) accountID := "" // Use deviceID from /info as canonical identifier (MAC address) deviceID := info.DeviceID if deviceID == "" { log.Printf("No deviceID found in /info response for device '%s' at %s", sanitizeLog(info.Name), sanitizeLog(deviceIP)) return fmt.Errorf("no deviceID found in /info response for device at %s - cannot sync without canonical device identifier", deviceIP) } log.Printf("Using deviceID '%s' for sync operations (MAC address from /info)", sanitizeLog(deviceID)) if info.MargeAccountUUID != "" { accountID = info.MargeAccountUUID } if accountID == "" { // Try to find account ID from existing device entries if info didn't have it devices, _ := m.DataStore.ListAllDevices() for i := range devices { if devices[i].DeviceSerialNumber == info.SerialNumber || devices[i].DeviceID == info.DeviceID { accountID = devices[i].AccountID break } } } if accountID == "" { accountID = "default" } // 2. Fetch Presets from :8090 m.syncPresets(deviceIP, accountID, deviceID) // 3. Fetch Recents from :8090 m.syncRecents(deviceIP, accountID, deviceID) // 4. Fetch Sources m.syncSources(deviceIP, accountID, deviceID) // 5. Nudge the device to re-render its source list. After a factory // reset (issue #234) the speaker's /sources only lists the always-on // local entries until it receives a notification; // the reporter's workaround was to POST this by hand. Wiring it into // the sync flow means the user gets the visible recovery for free // after they click Data Sync — re-pairing (which the wizard already // detects + prompts for) is the orthogonal half of the fix. m.notifySpeakerSourcesUpdated(deviceIP, deviceID) // 6. Create off-device backup of system configuration _ = m.BackupConfigOffDevice(deviceIP) return nil } func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) { presetsURL := fmt.Sprintf("http://%s:8090/presets", deviceIP) if _, _, splitErr := net.SplitHostPort(deviceIP); splitErr == nil { presetsURL = fmt.Sprintf("http://%s/presets", deviceIP) } log.Printf("[SYNC] Syncing presets for %s", sanitizeLog(deviceIP)) resp, err := m.HTTPGet(presetsURL) if err != nil { log.Printf("[SYNC_ERR] Failed to fetch presets for %s: %v", sanitizeLog(deviceIP), err) return } defer func() { _ = resp.Body.Close() }() var ps models.Presets if decodeErr := xml.NewDecoder(resp.Body).Decode(&ps); decodeErr != nil { return } var servicePresets []models.ServicePreset for _, p := range ps.Preset { // IsEmpty catches both placeholder shapes a SoundTouch device // can emit: self-closing (issue #308) and // . Neither carries // real playable data and persisting them would surface as // junk entries in the admin web UI. if p.IsEmpty() { continue } createdOn := "" if p.CreatedOn != nil { createdOn = strconv.FormatInt(*p.CreatedOn, 10) } updatedOn := "" if p.UpdatedOn != nil { updatedOn = strconv.FormatInt(*p.UpdatedOn, 10) } servicePresets = append(servicePresets, models.ServicePreset{ ServiceContentItem: models.ServiceContentItem{ ID: strconv.Itoa(p.ID), Name: p.ContentItem.ItemName, Source: p.ContentItem.Source, Type: p.ContentItem.Type, Location: p.ContentItem.Location, SourceAccount: p.ContentItem.SourceAccount, SourceID: "", // Preset doesn't have SourceID in ContentItem usually IsPresetable: strconv.FormatBool(p.ContentItem.IsPresetable), }, ID: strconv.Itoa(p.ID), ButtonNumber: strconv.Itoa(p.ID), ContainerArt: p.ContentItem.ContainerArt, CreatedOn: createdOn, UpdatedOn: updatedOn, }) } _ = m.DataStore.SavePresets(accountID, deviceID, servicePresets) } func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) { recentsURL := fmt.Sprintf("http://%s:8090/recents", deviceIP) if _, _, splitErr := net.SplitHostPort(deviceIP); splitErr == nil { recentsURL = fmt.Sprintf("http://%s/recents", deviceIP) } resp, err := m.HTTPGet(recentsURL) if err != nil { return } defer func() { _ = resp.Body.Close() }() var rr models.RecentsResponse if decodeErr := xml.NewDecoder(resp.Body).Decode(&rr); decodeErr != nil { return } var serviceRecents []models.ServiceRecent for _, r := range rr.Items { if r.ContentItem == nil { continue } serviceRecents = append(serviceRecents, models.ServiceRecent{ ServiceContentItem: models.ServiceContentItem{ ID: r.ID, Name: r.ContentItem.ItemName, Source: r.ContentItem.Source, Type: r.ContentItem.Type, Location: r.ContentItem.Location, SourceAccount: r.ContentItem.SourceAccount, SourceID: "", // RecentsResponseItem doesn't have SourceID usually IsPresetable: strconv.FormatBool(r.ContentItem.IsPresetable), ContainerArt: r.ContentItem.ContainerArt, }, DeviceID: r.DeviceID, UtcTime: strconv.FormatInt(r.UTCTime, 10), }) } _ = m.DataStore.SaveRecents(accountID, deviceID, serviceRecents) } func (m *Manager) syncSources(deviceIP, accountID, deviceID string) { client := m.NewSSH(deviceIP) sourcesXML, err := client.Run("cat /mnt/nv/BoseApp-Persistence/1/Sources.xml") if err == nil && sourcesXML != "" { var srs struct { Sources []models.ConfiguredSource `xml:"source"` } if xmlErr := xml.Unmarshal([]byte(sourcesXML), &srs); xmlErr == nil { // After unmarshaling from SSH, ensure legacy fields are synced for internal use for i := range srs.Sources { s := &srs.Sources[i] s.SourceKeyType = s.SourceKey.Type s.SourceKeyAccount = s.SourceKey.Account } // Drop device-local/transient sources without a resolvable // sourceproviderid (e.g. STORED_MUSIC_MEDIA_RENDERER, UPNP). // Persisting them causes /full to emit an empty // which the speaker rejects as INVALID_SOURCE // (#334). srs.Sources = filterServableSources(srs.Sources, deviceID) _ = m.DataStore.SaveConfiguredSources(accountID, deviceID, srs.Sources) return } } // Fallback to :8090/sources sourcesURL := fmt.Sprintf("http://%s:8090/sources", deviceIP) if _, _, splitErr := net.SplitHostPort(deviceIP); splitErr == nil { sourcesURL = fmt.Sprintf("http://%s/sources", deviceIP) } resp, err := m.HTTPGet(sourcesURL) if err != nil { return } defer func() { _ = resp.Body.Close() }() var srs models.Sources if decodeErr := xml.NewDecoder(resp.Body).Decode(&srs); decodeErr == nil { var configuredSources []models.ConfiguredSource for _, s := range srs.SourceItem { cs := models.ConfiguredSource{ DisplayName: s.DisplayName, Secret: "", SecretType: "", } if s.Status == "READY" { cs.SecretType = "token" } if s.Source == constants.ProviderSpotify { cs.SecretType = "token_version_3" } cs.SourceKey.Type = s.Source cs.SourceKey.Account = s.SourceAccount // Also set legacy fields for now cs.SourceKeyType = s.Source cs.SourceKeyAccount = s.SourceAccount configuredSources = append(configuredSources, cs) } // Drop device-local/transient sources without a resolvable // sourceproviderid (e.g. STORED_MUSIC_MEDIA_RENDERER, UPNP). // Persisting them causes /full to emit an empty // which the speaker rejects as INVALID_SOURCE (#334). configuredSources = filterServableSources(configuredSources, deviceID) _ = m.DataStore.SaveConfiguredSources(accountID, deviceID, configuredSources) } } // filterServableSources returns a copy of srcs containing only sources that // have a resolvable sourceproviderid. Device-local / transient slots without // one (STORED_MUSIC_MEDIA_RENDERER, UPNP, INVALID_SOURCE, …) are silently // dropped and logged as a single summary line. func filterServableSources(srcs []models.ConfiguredSource, deviceID string) []models.ConfiguredSource { var servable []models.ConfiguredSource var dropped []string for i := range srcs { if marge.HasResolvableProviderID(srcs[i]) { servable = append(servable, srcs[i]) } else { dropped = append(dropped, sanitizeLog(srcs[i].SourceKeyType)) } } if len(dropped) > 0 { log.Printf("[SYNC] device %s: dropping %d unresolvable source(s) on import (types: %v)", sanitizeLog(deviceID), len(dropped), dropped) } return servable } // notifySpeakerSourcesUpdated POSTs the notification // to /notification on the device, mirroring the manual workaround // documented in issue #234. The device responds by re-evaluating its // /sources catalogue — after a factory reset that's what makes TUNEIN / // LOCAL_INTERNET_RADIO / DEEZER / linked Spotify accounts reappear in // the list. The wizard's pair-account flow restores playback (it // recreates the Marge.xml token); this nudge restores the *visible* // source list. Both are needed for a full #234 recovery; this is the // half AfterTouch can automate without user input. // // Delegates the HTTP plumbing to pkg/client.Client.NotifySourcesUpdated, // which is the same path handlers_mgmt.go uses after music-service // account changes — keeping the wire-shape definition in one place // (pkg/models.NewSourcesUpdatedNotification). // // Fire-and-forget: a network failure (or the device returning an // unexpected response) doesn't fail the surrounding sync. The sync's // persisted state is already on disk by the time we fire the // notification; whether the device acts on it is observable on the // next sync. func (m *Manager) notifySpeakerSourcesUpdated(deviceIP, deviceID string) { c := client.NewClientFromHost(deviceIP) if err := c.NotifySourcesUpdated(deviceID); err != nil { log.Printf("[SYNC] notify %s: %v", sanitizeLog(deviceIP), err) return } log.Printf("[SYNC] notify %s sourcesUpdated -> ok", sanitizeLog(deviceIP)) }