mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
Pins the ordering invariant fixed in the preceding commit. Builds a fake-speaker scenario where: - SSH is unavailable (every SSH-driven axis stays false) - telnet getpdo reports the AfterTouch hostname Pre-fix, checkIsMigratedFromProbe ran before the telnet channel was drained, so summary.TelnetVerifiedConfig was empty when isTelnetMigrated read it — the telnet axis came back false and summary.IsMigrated followed. The CLI's `setup verify` exited non-zero, the web UI rendered "Not Migrated". Reproduced by foob61451 on #293. The test asserts: - summary.TelnetVerifiedConfig is populated (sanity guard — the downstream assertions are meaningless if the probe didn't run) - summary.TelnetMigrated == true - summary.IsMigrated == true Verified locally: the test PASSES with the ordering fix applied and FAILS without it. Failure messages name PR #294 by number so a future regression points at the same code path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2710 lines
91 KiB
Go
2710 lines
91 KiB
Go
// 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/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"`
|
||
MirrorEnabled bool `json:"mirror_enabled"`
|
||
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
|
||
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"`
|
||
PreferredSource string `json:"preferred_source,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.
|
||
type SSHClient interface {
|
||
Run(command string) (string, error)
|
||
UploadContent(content []byte, remotePath string) 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})
|
||
},
|
||
HTTPGet: http.Get,
|
||
MgmtUsername: "admin",
|
||
MgmtPassword: "change_me!",
|
||
}
|
||
}
|
||
|
||
// 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 = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" + string(xmlContent)
|
||
|
||
// 2b. Planned network config (hosts entries, resolv.conf preview, resolve error)
|
||
m.populatePlannedNetworkConfig(summary, deviceIP, targetURL)
|
||
|
||
// 3. Provide HTTPS URL for testing (consumed by the migration UI)
|
||
summary.ServerHTTPSURL = m.buildServerHTTPSURL(targetURL)
|
||
|
||
// 4. Mirroring settings
|
||
if m.DataStore != nil {
|
||
settings, err := m.DataStore.GetSettings()
|
||
if err == nil {
|
||
summary.MirrorEnabled = settings.MirrorEnabled
|
||
summary.MirrorEndpoints = settings.MirrorEndpoints
|
||
summary.SkipMirrorEndpoints = settings.SkipMirrorEndpoints
|
||
summary.PreferredSource = settings.PreferredSource
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
parsedURL, err := url.Parse(targetURL)
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
hostName := parsedURL.Hostname()
|
||
if hostName == "" || hostName == "localhost" {
|
||
return
|
||
}
|
||
|
||
// Resolve locally only. The "from-device" lookup that resolveIP can
|
||
// do via SSH (`ping -c 1 host`) costs another fresh SSH handshake
|
||
// plus the ping's own runtime — easily 2–5 s on firmware-27 devices
|
||
// — and the result feeds only the PlannedResolv/PlannedHosts preview.
|
||
// For the actual apply paths (migrateViaHosts/migrateViaResolv) the
|
||
// device-side resolution is still used; this is only the preview.
|
||
hostIP, resolveErr := m.resolveIP(hostName, nil)
|
||
if 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",
|
||
"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()
|
||
|
||
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 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 checks if the local CA certificate is already in
|
||
// the device's trust store. The CALabel grep works regardless of whether
|
||
// Manager.Crypto is configured — only the secondary "match cert payload"
|
||
// fallback needs it. CLI callers without Crypto can therefore still
|
||
// detect a previously-trusted CA.
|
||
func (m *Manager) checkCACertTrusted(summary *MigrationSummary, deviceIP string) {
|
||
client := m.NewSSH(deviceIP)
|
||
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
|
||
|
||
// Primary check: 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
|
||
return
|
||
}
|
||
|
||
// Secondary check (only when Manager.Crypto is configured): match
|
||
// the actual cert payload — covers older injections that lack the
|
||
// label.
|
||
if m.Crypto == nil {
|
||
return
|
||
}
|
||
|
||
caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
// We look for the first part of the certificate (e.g. the first 64 chars of the base64 data)
|
||
// to see if it's already in the bundle.
|
||
lines := strings.Split(string(caCertPEM), "\n")
|
||
|
||
var certData string
|
||
|
||
for _, line := range lines {
|
||
if !strings.Contains(line, "BEGIN CERTIFICATE") && !strings.Contains(line, "END CERTIFICATE") && line != "" {
|
||
certData = line
|
||
break
|
||
}
|
||
}
|
||
|
||
if certData == "" {
|
||
return
|
||
}
|
||
|
||
// Use grep to check for the certificate data in the bundle
|
||
_, err = client.Run(fmt.Sprintf("grep -F %q %s", certData, bundlePath))
|
||
if err == nil {
|
||
summary.CACertTrusted = true
|
||
}
|
||
}
|
||
|
||
// 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("<?xml version=\"1.0\" encoding=\"utf-8\"?>\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)
|
||
}
|
||
}
|
||
|
||
return logs, nil
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// 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 <bundlePath>.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",
|
||
"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", deviceIP, 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)
|
||
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", deviceIP, 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
|
||
}
|
||
|
||
// 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.
|
||
// An error is returned whenever the SSH ping did not produce the IP, so callers that
|
||
// write config to the device can abort rather than risk writing an unresolvable hostname.
|
||
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", host, 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("resolved %q to %s from service, not from device — result may be wrong if NAT or split-DNS is in use", 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'",
|
||
deviceIP, info.Name, info.DeviceID, 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", info.Name, 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)", 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 <sourcesUpdated/> 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", deviceIP)
|
||
|
||
resp, err := m.HTTPGet(presetsURL)
|
||
if err != nil {
|
||
log.Printf("[SYNC_ERR] Failed to fetch presets for %s: %v", 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 {
|
||
if p.ContentItem == nil {
|
||
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
|
||
}
|
||
|
||
_ = 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)
|
||
}
|
||
|
||
_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, configuredSources)
|
||
}
|
||
}
|
||
|
||
// notifySpeakerSourcesUpdated POSTs the <sourcesUpdated/> 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", deviceIP, err)
|
||
return
|
||
}
|
||
|
||
log.Printf("[SYNC] notify %s sourcesUpdated -> ok", deviceIP)
|
||
}
|