mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 09:06:14 +00:00
This update adds explicit verification checks after applying changes via XML, Hosts, and ResolvConf migration methods. The service now verifies that configuration files are correctly updated on the device before considering the migration successful, preventing unreliable states.
2031 lines
64 KiB
Go
2031 lines
64 KiB
Go
// Package setup contains speaker migration and configuration helpers.
|
|
package setup
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"fmt"
|
|
"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/datastore"
|
|
"github.com/gesellix/bose-soundtouch/pkg/service/ssh"
|
|
)
|
|
|
|
// 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"
|
|
)
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// SSHClient defines the interface for SSH operations.
|
|
type SSHClient interface {
|
|
Run(command string) (string, error)
|
|
UploadContent(content []byte, remotePath string) 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
|
|
|
|
// GetDNSRunning is an optional callback to check the actual state of the DNS server.
|
|
GetDNSRunning func() (bool, 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)
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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"`
|
|
MaccAddress string `xml:"maccAddress" json:"maccAddress"`
|
|
SoftwareVer string `xml:"-" json:"softwareVersion"`
|
|
SerialNumber string `xml:"-" json:"serialNumber"`
|
|
MargeAccountUUID string `xml:"margeAccountUUID" json:"margeAccountUUID"`
|
|
Components []struct {
|
|
Category string `xml:"componentCategory"`
|
|
SoftwareVersion string `xml:"softwareVersion"`
|
|
SerialNumber string `xml:"serialNumber"`
|
|
} `xml:"components>component" json:"-"`
|
|
}
|
|
|
|
// 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 := http.Get(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 := xml.NewDecoder(resp.Body).Decode(&infoXML); err != nil {
|
|
return nil, fmt.Errorf("failed to decode info XML from %s: %w", infoURL, err)
|
|
}
|
|
|
|
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 &infoXML, nil
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
|
|
// Populate device info from datastore and live info
|
|
m.populateDeviceInfo(summary, deviceIP)
|
|
|
|
// 1. Initial planned config
|
|
plannedCfg := PrivateCfg{
|
|
MargeServerUrl: fmt.Sprintf("%s/marge", 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. Initial planned hosts config
|
|
parsedURL, err := url.Parse(targetURL)
|
|
if err == nil {
|
|
hostName := parsedURL.Hostname()
|
|
if hostName != "" && hostName != "localhost" {
|
|
client := m.NewSSH(deviceIP)
|
|
hostIP := m.resolveIP(hostName, client)
|
|
|
|
// Predicted aftertouch.resolv.conf
|
|
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",
|
|
}
|
|
|
|
var hostsLines []string
|
|
for _, domain := range domains {
|
|
hostsLines = append(hostsLines, fmt.Sprintf("%s\t%s", hostIP, domain))
|
|
}
|
|
|
|
summary.PlannedHosts = strings.Join(hostsLines, "\n")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
if parsedURL, err := url.Parse(targetURL); err == nil {
|
|
hostIP := parsedURL.Hostname()
|
|
if hostIP != "" {
|
|
// Find HTTPS port from environment or default
|
|
httpsPort := os.Getenv("HTTPS_PORT")
|
|
if httpsPort == "" {
|
|
httpsPort = "8443"
|
|
}
|
|
|
|
summary.ServerHTTPSURL = fmt.Sprintf("https://%s:%s/health", hostIP, httpsPort)
|
|
}
|
|
}
|
|
|
|
// 6. Check if migrated
|
|
m.checkIsMigrated(summary, deviceIP)
|
|
|
|
return summary, nil
|
|
}
|
|
|
|
// checkIsMigrated determines if the device is already migrated to AfterTouch.
|
|
func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
|
|
if !summary.SSHSuccess {
|
|
return
|
|
}
|
|
|
|
// Case 1: XML Migration
|
|
// Check if any URL in the current config points to our server (targetURL)
|
|
if summary.ParsedCurrentConfig != nil {
|
|
targetURL := m.ServerURL
|
|
// Strip protocol for comparison if needed, or just check for substring
|
|
parsedTarget, err := url.Parse(targetURL)
|
|
if err == nil {
|
|
targetHost := parsedTarget.Hostname()
|
|
if strings.Contains(summary.ParsedCurrentConfig.MargeServerUrl, targetHost) ||
|
|
strings.Contains(summary.ParsedCurrentConfig.StatsServerUrl, targetHost) ||
|
|
strings.Contains(summary.ParsedCurrentConfig.SwUpdateUrl, targetHost) ||
|
|
strings.Contains(summary.ParsedCurrentConfig.BmxRegistryUrl, targetHost) {
|
|
summary.IsMigrated = true
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Case 2: /etc/hosts + Trust CA Migration
|
|
// Check if /etc/hosts contains redirections for Bose domains
|
|
client := m.NewSSH(deviceIP)
|
|
|
|
hostsContent, err := client.Run("cat /etc/hosts")
|
|
if err == nil {
|
|
boseDomains := []string{
|
|
"streaming.bose.com",
|
|
"updates.bose.com",
|
|
"stats.bose.com",
|
|
"bmx.bose.com",
|
|
}
|
|
for _, domain := range boseDomains {
|
|
if strings.Contains(hostsContent, domain) {
|
|
// If CA is also trusted, it's a strong indicator of migration
|
|
if summary.CACertTrusted {
|
|
summary.IsMigrated = true
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Case 3: /etc/resolv.conf Migration (including Aftertouch hook)
|
|
// Check if /etc/resolv.conf contains our target nameserver OR if hook marker exists
|
|
if summary.SSHSuccess {
|
|
// Check for aftertouch.resolv.conf
|
|
if _, err := client.Run("[ -f /mnt/nv/aftertouch.resolv.conf ]"); err == nil {
|
|
if summary.CACertTrusted {
|
|
summary.IsMigrated = true
|
|
return
|
|
}
|
|
}
|
|
|
|
if summary.CurrentResolvConf != "" {
|
|
targetURL := m.ServerURL
|
|
|
|
parsedTarget, err := url.Parse(targetURL)
|
|
if err == nil {
|
|
targetHost := parsedTarget.Hostname()
|
|
if strings.Contains(summary.CurrentResolvConf, targetHost) {
|
|
if summary.CACertTrusted {
|
|
summary.IsMigrated = true
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
func (m *Manager) applyProxyOptions(plannedCfg *PrivateCfg, proxyURL string, options map[string]string, currentCfg *PrivateCfg) {
|
|
if proxyURL == "" || currentCfg == nil {
|
|
return
|
|
}
|
|
|
|
if options["marge"] == "upstream" && currentCfg.MargeServerUrl != "" {
|
|
plannedCfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl)
|
|
}
|
|
|
|
if options["stats"] == "upstream" && currentCfg.StatsServerUrl != "" {
|
|
plannedCfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl)
|
|
}
|
|
|
|
if options["sw_update"] == "upstream" && currentCfg.SwUpdateUrl != "" {
|
|
plannedCfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl)
|
|
}
|
|
|
|
if options["bmx"] == "upstream" && currentCfg.BmxRegistryUrl != "" {
|
|
plannedCfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, 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
|
|
}
|
|
|
|
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: fmt.Sprintf("%s/marge", 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 have a proxyURL and can read current config, use it
|
|
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)
|
|
// Try to copy existing config to .original, ensuring filesystem is writable
|
|
if output, err := client.Run(fmt.Sprintf("%s && cp %s %s.original", rwCmd, remotePath, remotePath)); err != nil {
|
|
logs += fmt.Sprintf("Warning: failed to cp backup config: %v (output: %s)\n", err, output)
|
|
fmt.Printf("Warning: failed to cp backup config: %v (output: %s)\n", err, output)
|
|
// Fallback to manual upload if cp failed (might not have cp?)
|
|
if config, err := client.Run(fmt.Sprintf("cat %s", remotePath)); err == nil && config != "" {
|
|
if err := client.UploadContent([]byte(config), remotePath+".original"); err != nil {
|
|
logs += "Warning: failed to upload backup config: " + err.Error() + "\n"
|
|
fmt.Printf("Warning: failed to upload backup config: %v\n", err)
|
|
} else {
|
|
logs += "Uploaded backup config via fallback\n"
|
|
}
|
|
}
|
|
} else {
|
|
logs += "Copied backup config to .original\n"
|
|
}
|
|
} else {
|
|
logs += "Backup .original already exists\n"
|
|
}
|
|
|
|
// 1. Upload the configuration (rw is handled by calling it before if needed, but UploadContent uses cat > which needs rw)
|
|
// We'll wrap the upload in a way that EnsureRemoteServices and others might benefit,
|
|
// but UploadContent is a separate method. We should probably add rw to UploadContent or call it before.
|
|
// Actually, let's call rw before UploadContent here.
|
|
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)
|
|
}
|
|
|
|
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 := m.resolveIP(hostName, client)
|
|
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",
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
lines := strings.Split(hostsContent, "\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"
|
|
}
|
|
|
|
// 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 verification, err := client.Run("cat /etc/hosts"); err == nil {
|
|
for _, domain := range domains {
|
|
if !strings.Contains(verification, domain) || !strings.Contains(verification, hostIP) {
|
|
return logs, fmt.Errorf("verification failed: /etc/hosts on %s does not contain expected redirection for %s", deviceIP, domain)
|
|
}
|
|
}
|
|
logs += "Verified /etc/hosts on device\n"
|
|
} else {
|
|
logs += fmt.Sprintf("Warning: could not verify /etc/hosts on device: %v\n", err)
|
|
}
|
|
|
|
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) 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 := m.resolveIP(hostName, client)
|
|
logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP)
|
|
|
|
// 2. Prepare /mnt/nv/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/aftertouch.resolv.conf
|
|
// Ensure /mnt/nv exists
|
|
_, _ = client.Run("mkdir -p /mnt/nv")
|
|
|
|
if err := client.UploadContent([]byte(resolvContent), "/mnt/nv/aftertouch.resolv.conf"); err != nil {
|
|
return logs, fmt.Errorf("failed to upload /mnt/nv/aftertouch.resolv.conf: %w", err)
|
|
}
|
|
|
|
logs += "Uploaded /mnt/nv/aftertouch.resolv.conf\n"
|
|
|
|
// 4. Update /mnt/nv/rc.local with idempotent patch
|
|
rcLocalPath := "/mnt/nv/rc.local"
|
|
targetDHCPFile := "/etc/udhcpc.d/50default"
|
|
hookMarker := "/mnt/nv/aftertouch.resolv.conf"
|
|
|
|
// Check if rc.local exists and read it
|
|
currentRcLocal, rcErr := client.Run(fmt.Sprintf("cat %s", rcLocalPath))
|
|
if rcErr != nil {
|
|
currentRcLocal = ""
|
|
}
|
|
|
|
patchLogic := fmt.Sprintf(`
|
|
# Aftertouch DNS hook: 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
|
|
`, hookMarker, targetDHCPFile, hookMarker, targetDHCPFile, targetDHCPFile, hookMarker, hookMarker, targetDHCPFile, hookMarker, hookMarker, hookMarker)
|
|
|
|
if !strings.Contains(currentRcLocal, hookMarker) {
|
|
newRcLocal := currentRcLocal
|
|
// 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))
|
|
} else {
|
|
logs += fmt.Sprintf("%s already contains Aftertouch hook logic\n", rcLocalPath)
|
|
}
|
|
|
|
// 5. Apply patch immediately to /etc/udhcpc.d/50default
|
|
out, _ := client.Run(rwCmd)
|
|
logs += rwCmd + ": " + out + "\n"
|
|
|
|
// 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 {
|
|
logs += fmt.Sprintf("Failed to apply patch immediately to %s: %v\n", targetDHCPFile, err)
|
|
} else {
|
|
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 {
|
|
logs += fmt.Sprintf("Warning: could not 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 {
|
|
// 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 {
|
|
logs += fmt.Sprintf("Failed to apply patch immediately to %s: %v\n", targetScript, err)
|
|
} else {
|
|
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 {
|
|
logs += fmt.Sprintf("Warning: could not 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
|
|
}
|
|
|
|
// 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/aftertouch.resolv.conf"
|
|
rcLocalPath := "/mnt/nv/rc.local"
|
|
targetDHCPFile := "/etc/udhcpc.d/50default"
|
|
|
|
if _, err := client.Run(fmt.Sprintf("[ -f %s ]", aftertouchConfPath)); err == nil {
|
|
logs += fmt.Sprintf("Removing %s\n", aftertouchConfPath)
|
|
fmt.Printf("Removing %s\n", aftertouchConfPath)
|
|
_, _ = client.Run(fmt.Sprintf("rm %s", aftertouchConfPath))
|
|
}
|
|
|
|
if currentRcLocal, err := client.Run(fmt.Sprintf("cat %s", rcLocalPath)); err == nil {
|
|
// 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
|
|
}
|
|
|
|
if strings.Contains(currentRcLocal, aftertouchConfPath) || strings.Contains(currentRcLocal, "# Aftertouch DNS hook") {
|
|
logs += fmt.Sprintf("Removing Aftertouch hook logic from %s\n", rcLocalPath)
|
|
fmt.Printf("Removing Aftertouch hook logic from %s\n", rcLocalPath)
|
|
|
|
// Simple removal: filter out lines between the marker and the 'fi'
|
|
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)
|
|
}
|
|
}
|
|
|
|
newRcLocal := strings.Join(newLines, "\n")
|
|
if err := client.UploadContent([]byte(newRcLocal), rcLocalPath); err != nil {
|
|
fmt.Printf("Warning: failed to update %s: %v\n", rcLocalPath, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
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) 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
|
|
}
|
|
|
|
// Reboot reboots the speaker at the given IP.
|
|
func (m *Manager) Reboot(deviceIP string) (string, error) {
|
|
client := m.NewSSH(deviceIP)
|
|
rwCmd := "(rw || mount -o remount,rw /)"
|
|
|
|
fmt.Printf("Rebooting speaker at %s\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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
return m.resolveIP(hostName, client), 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 {
|
|
return m.resolveIP(host, nil)
|
|
}
|
|
|
|
func (m *Manager) resolveIP(host string, client SSHClient) string {
|
|
if net.ParseIP(host) != nil {
|
|
return host
|
|
}
|
|
|
|
// 1. Try resolving FROM the device via SSH (best for containers/NAT)
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Fallback: resolve FROM the service itself
|
|
ips, err := net.LookupIP(host)
|
|
if err != nil || len(ips) == 0 {
|
|
return host // Fallback to host if resolution fails
|
|
}
|
|
|
|
// Prefer IPv4
|
|
for _, ip := range ips {
|
|
if ip.To4() != nil {
|
|
return ip.String()
|
|
}
|
|
}
|
|
|
|
return ips[0].String()
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
accountID := ""
|
|
|
|
deviceID := info.SerialNumber
|
|
if deviceID == "" {
|
|
deviceID = deviceIP
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
resp, err := http.Get(presetsURL)
|
|
if err != nil {
|
|
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),
|
|
},
|
|
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 := http.Get(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),
|
|
},
|
|
DeviceID: r.DeviceID,
|
|
UtcTime: strconv.FormatInt(r.UTCTime, 10),
|
|
ContainerArt: r.ContentItem.ContainerArt,
|
|
})
|
|
}
|
|
|
|
_ = 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 := http.Get(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,
|
|
ID: s.Source,
|
|
SecretType: string(s.Status),
|
|
}
|
|
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)
|
|
}
|
|
}
|