mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
Refactors telnetURLConfigCommands into a telnetURLs value type with explicit per-field URLs (Marge, Stats, SwUpdate, BmxRegistry) and adds telnetURLsFromOptions to resolve those four URLs from a base targetURL plus optional per-field overrides via the migration options map (marge_url, stats_url, sw_update_url, bmx_url). Envswitch derivation rule: arg1 = u.Marge verbatim, arg2 = u.SwUpdate verbatim. The soundcork case (Marge has /marge appended) is handled without any branching — envswitch arg1 carries the same suffix and the parallel persistence layer stays consistent with the runtime layer on the next reboot. The default path is unchanged for users who only enter a base URL: all four fields share targetURL with the canonical /updates/soundtouch and /bmx/registry/v1/services suffixes. MigrateSpeaker plumbs the options map through so the existing handler's option dictionary works for telnet without UI changes; the UI can layer per-field input on top later. Existing telnet migration tests updated to call the new signature. TestMigrateViaTelnet_SoundcorkMargeSuffixPropagatesToEnvswitch is the load-bearing regression test for the derivation rule. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2528 lines
81 KiB
Go
2528 lines
81 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"`
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
// 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.
|
|
//
|
|
// The telnet-based check runs first and 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 still run when SSH is reachable to cover the
|
|
// /etc/hosts and /etc/resolv.conf migration variants, neither of which
|
|
// shows up in `getpdo CurrentSystemConfiguration`.
|
|
func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
|
|
if m.isTelnetMigrated(summary) {
|
|
summary.IsMigrated = true
|
|
}
|
|
|
|
if !summary.SSHSuccess {
|
|
return
|
|
}
|
|
|
|
client := m.NewSSH(deviceIP)
|
|
|
|
if m.isXMLMigrated(summary) || m.isHostsMigrated(client, summary) || m.isResolvConfMigrated(client, summary) {
|
|
summary.IsMigrated = true
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|