mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 16:46:17 +00:00
Adds applyURLOverrides — a tiny helper that, given a PrivateCfg and the migration options map, copies any non-empty marge_url / stats_url / sw_update_url / bmx_url value into the matching PrivateCfg field. The helper runs after applyProxyOptions in both the read path (GetMigrationSummary's planned-config preview) and the write path (migrateViaXML's actual XML upload), so the planned diff and the file the migration writes both reflect what the user typed. Precedence: a literal *_url override wins over the legacy self/proxied/original mode set on the same field, because the user picked a URL and the migration honors it verbatim. Empty/missing overrides leave the field unchanged. The legacy mode handling stays in place for API back-compat — only the UI is moving away from it. Tests cover the helper directly, the override-vs-mode precedence rule, and a full GetMigrationSummary round-trip that verifies the override shows up in the rendered PlannedConfig XML. This is the data-layer half of the upcoming unified per-field URL editor in the Plan card; no UI changes here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2596 lines
83 KiB
Go
2596 lines
83 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"
|
|
|
|
"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
|
|
|
|
// 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)
|
|
},
|
|
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. Check SSH and read current config
|
|
currentConfig, err := m.checkCurrentConfig(summary, deviceIP)
|
|
if err == nil && currentConfig != "" {
|
|
summary.CurrentConfig = currentConfig
|
|
fmt.Printf("Current config from %s (length: %d):\n%q\n", deviceIP, len(currentConfig), currentConfig)
|
|
|
|
// Parse current config
|
|
var currentCfg PrivateCfg
|
|
if xml.Unmarshal([]byte(currentConfig), ¤tCfg) == nil {
|
|
summary.ParsedCurrentConfig = ¤tCfg
|
|
|
|
if proxyURL == "" {
|
|
proxyURL = targetURL
|
|
}
|
|
|
|
// Apply options if provided
|
|
if options != nil {
|
|
m.applyProxyOptions(&plannedCfg, proxyURL, options, ¤tCfg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
// Note: CurrentConfig is set by checkCurrentConfig in all cases (success or failure)
|
|
|
|
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. Check for remote services files
|
|
m.checkRemoteServices(summary, deviceIP)
|
|
|
|
// 4. Check if CA certificate is trusted
|
|
m.checkCACertTrusted(summary, deviceIP)
|
|
|
|
// 4b. Check current /etc/resolv.conf
|
|
if summary.SSHSuccess {
|
|
client := m.NewSSH(deviceIP)
|
|
if resolvConf, err := client.Run("cat /etc/resolv.conf"); err == nil {
|
|
summary.CurrentResolvConf = resolvConf
|
|
}
|
|
}
|
|
|
|
// 5. Provide HTTPS URL for testing
|
|
summary.ServerHTTPSURL = m.buildServerHTTPSURL(targetURL)
|
|
|
|
// 6. Check if migrated
|
|
m.checkIsMigrated(summary, deviceIP)
|
|
|
|
// 7. 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
|
|
}
|
|
}
|
|
|
|
// 8. 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
|
|
|
|
// 9. 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, deviceIP, targetURL string) {
|
|
parsedURL, err := url.Parse(targetURL)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
hostName := parsedURL.Hostname()
|
|
if hostName == "" || hostName == "localhost" {
|
|
return
|
|
}
|
|
|
|
client := m.NewSSH(deviceIP)
|
|
|
|
hostIP, resolveErr := m.resolveIP(hostName, client)
|
|
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")
|
|
}
|
|
|
|
func (m *Manager) buildServerHTTPSURL(targetURL string) string {
|
|
parsedURL, err := url.Parse(targetURL)
|
|
if err != nil || parsedURL.Hostname() == "" {
|
|
return ""
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// checkRemoteServices checks for remote services files on the device
|
|
func (m *Manager) checkRemoteServices(summary *MigrationSummary, deviceIP string) {
|
|
client := m.NewSSH(deviceIP)
|
|
locations := []string{
|
|
"/etc/remote_services",
|
|
"/mnt/nv/remote_services",
|
|
"/tmp/remote_services",
|
|
}
|
|
|
|
for _, loc := range locations {
|
|
if _, err := client.Run(fmt.Sprintf("[ -e %s ]", loc)); err == nil {
|
|
summary.RemoteServicesFound = append(summary.RemoteServicesFound, loc)
|
|
|
|
summary.RemoteServicesEnabled = true
|
|
if loc != "/tmp/remote_services" {
|
|
summary.RemoteServicesPersistent = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkCACertTrusted checks if the local CA certificate is already in the device's trust store.
|
|
func (m *Manager) checkCACertTrusted(summary *MigrationSummary, deviceIP string) {
|
|
if m.Crypto == nil {
|
|
return
|
|
}
|
|
|
|
client := m.NewSSH(deviceIP)
|
|
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
|
|
|
|
// First, check for the label
|
|
output, err := client.Run(fmt.Sprintf("grep -F %q %s", CALabel, bundlePath))
|
|
if err == nil && strings.Contains(output, CALabel) {
|
|
summary.CACertTrusted = true
|
|
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 {
|
|
// 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.
|
|
func (m *Manager) TrustCACert(deviceIP string) (string, error) {
|
|
client := m.NewSSH(deviceIP)
|
|
rwCmd := "(rw || mount -o remount,rw /)"
|
|
|
|
var logs string
|
|
|
|
caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read CA certificate: %w", err)
|
|
}
|
|
|
|
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
|
|
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) {
|
|
// Label found, let's replace the whole block between labels if we used them,
|
|
// or just remove the lines containing the label and re-append.
|
|
// For simplicity, let's remove everything between CALabel tags if we had them,
|
|
// but since we only had one line before, let's just remove lines containing CALabel
|
|
// and the cert data if possible.
|
|
// A better way is to rebuild the bundle without our CA.
|
|
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"
|
|
}
|
|
} else if bundleContent != "" && !strings.HasSuffix(bundleContent, "\n") {
|
|
bundleContent += "\n"
|
|
}
|
|
|
|
// Append with labels
|
|
labeledCert := fmt.Sprintf("\n%s\n%s%s\n", CALabel, string(caCertPEM), CALabel)
|
|
newBundleContent := bundleContent + labeledCert
|
|
|
|
if err := client.UploadContent([]byte(newBundleContent), bundlePath); err != nil {
|
|
return logs, fmt.Errorf("failed to update bundle: %w", err)
|
|
}
|
|
|
|
logs += "Uploaded updated bundle to " + bundlePath + "\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. 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)
|
|
}
|
|
}
|