mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
feat: implement dual migration (XML and /etc/hosts) with custom CA and HTTPS support. Added automated /etc/hosts redirection, Root CA injection, built-in HTTPS listener, and enhanced management UI with diagnostic tests.
This commit is contained in:
+441
-9
@@ -6,11 +6,25 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/crypto"
|
||||
"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"
|
||||
)
|
||||
|
||||
// SoundTouchSdkPrivateCfgPath is the path to the speaker's private configuration file on device.
|
||||
const SoundTouchSdkPrivateCfgPath = "/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml"
|
||||
|
||||
@@ -33,6 +47,7 @@ type MigrationSummary struct {
|
||||
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"`
|
||||
@@ -41,17 +56,34 @@ type MigrationSummary struct {
|
||||
DeviceModel string `json:"device_model,omitempty"`
|
||||
DeviceSerial string `json:"device_serial,omitempty"`
|
||||
FirmwareVersion string `json:"firmware_version,omitempty"`
|
||||
CACertTrusted bool `json:"ca_cert_trusted"`
|
||||
ServerHTTPSURL string `json:"server_https_url,omitempty"`
|
||||
}
|
||||
|
||||
// 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 soundcork service.
|
||||
type Manager struct {
|
||||
ServerURL string
|
||||
DataStore *datastore.DataStore
|
||||
Crypto *crypto.CertificateManager
|
||||
NewSSH func(host string) SSHClient
|
||||
}
|
||||
|
||||
// NewManager creates a new Manager with the given base server URL.
|
||||
func NewManager(serverURL string, ds *datastore.DataStore) *Manager {
|
||||
return &Manager{ServerURL: serverURL, DataStore: ds}
|
||||
func NewManager(serverURL string, ds *datastore.DataStore, cm *crypto.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
|
||||
@@ -162,9 +194,50 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
|
||||
|
||||
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)
|
||||
domains := []string{
|
||||
"streaming.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"bmx.bose.com",
|
||||
"content.api.bose.io",
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
@@ -212,7 +285,7 @@ func (m *Manager) populateDeviceInfo(summary *MigrationSummary, deviceIP string)
|
||||
// checkCurrentConfig reads and validates the current speaker configuration
|
||||
func (m *Manager) checkCurrentConfig(summary *MigrationSummary, deviceIP string) (string, error) {
|
||||
path := SoundTouchSdkPrivateCfgPath
|
||||
client := ssh.NewClient(deviceIP)
|
||||
client := m.NewSSH(deviceIP)
|
||||
|
||||
// Check if .original exists
|
||||
if _, checkErr := client.Run(fmt.Sprintf("[ -f %s.original ]", path)); checkErr == nil {
|
||||
@@ -282,7 +355,7 @@ func (m *Manager) applyProxyOptions(plannedCfg *PrivateCfg, proxyURL string, opt
|
||||
|
||||
// checkRemoteServices checks for remote services files on the device
|
||||
func (m *Manager) checkRemoteServices(summary *MigrationSummary, deviceIP string) {
|
||||
client := ssh.NewClient(deviceIP)
|
||||
client := m.NewSSH(deviceIP)
|
||||
locations := []string{
|
||||
"/etc/remote_services",
|
||||
"/mnt/nv/remote_services",
|
||||
@@ -301,12 +374,57 @@ func (m *Manager) checkRemoteServices(summary *MigrationSummary, deviceIP string
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
client := m.NewSSH(deviceIP)
|
||||
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
|
||||
// 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 soundcork service.
|
||||
func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options map[string]string) error {
|
||||
func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options map[string]string, method MigrationMethod) error {
|
||||
if targetURL == "" {
|
||||
targetURL = m.ServerURL
|
||||
}
|
||||
|
||||
if method == "" {
|
||||
method = MigrationMethodXML
|
||||
}
|
||||
|
||||
if method == MigrationMethodHosts {
|
||||
return m.migrateViaHosts(deviceIP, targetURL)
|
||||
}
|
||||
|
||||
if err := m.EnsureRemoteServices(deviceIP); err != nil {
|
||||
// Log but continue migration? Or fail? The requirement is "to ensure stable 'remote_services'"
|
||||
// Let's log it.
|
||||
@@ -324,7 +442,7 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
|
||||
}
|
||||
|
||||
// If we have a proxyURL and can read current config, use it
|
||||
client := ssh.NewClient(deviceIP)
|
||||
client := m.NewSSH(deviceIP)
|
||||
if currentConfig, err := client.Run(fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath)); err == nil && currentConfig != "" {
|
||||
var currentCfg PrivateCfg
|
||||
if xml.Unmarshal([]byte(currentConfig), ¤tCfg) == nil {
|
||||
@@ -388,7 +506,7 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
|
||||
|
||||
// BackupConfig creates a backup of the current configuration on the speaker.
|
||||
func (m *Manager) BackupConfig(deviceIP string) error {
|
||||
client := ssh.NewClient(deviceIP)
|
||||
client := m.NewSSH(deviceIP)
|
||||
remotePath := SoundTouchSdkPrivateCfgPath
|
||||
rwCmd := "(rw || mount -o remount,rw /)"
|
||||
|
||||
@@ -423,7 +541,7 @@ func (m *Manager) BackupConfig(deviceIP string) error {
|
||||
// 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) error {
|
||||
client := ssh.NewClient(deviceIP)
|
||||
client := m.NewSSH(deviceIP)
|
||||
rwCmd := "(rw || mount -o remount,rw /)"
|
||||
|
||||
// Try locations in order of preference
|
||||
@@ -450,9 +568,107 @@ func (m *Manager) EnsureRemoteServices(deviceIP string) error {
|
||||
return fmt.Errorf("failed to enable remote services in any of the locations: %v", locations)
|
||||
}
|
||||
|
||||
func (m *Manager) migrateViaHosts(deviceIP, targetURL string) error {
|
||||
client := m.NewSSH(deviceIP)
|
||||
rwCmd := "(rw || mount -o remount,rw /)"
|
||||
|
||||
// 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)
|
||||
|
||||
// 2. Prepare /etc/hosts entries
|
||||
domains := []string{
|
||||
"streaming.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"bmx.bose.com",
|
||||
"content.api.bose.io",
|
||||
}
|
||||
|
||||
hostsContent, err := client.Run("cat /etc/hosts")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read /etc/hosts: %w", err)
|
||||
}
|
||||
|
||||
for _, domain := range domains {
|
||||
if !strings.Contains(hostsContent, domain) {
|
||||
entry := fmt.Sprintf("%s\t%s", hostIP, domain)
|
||||
|
||||
if hostsContent != "" && !strings.HasSuffix(hostsContent, "\n") {
|
||||
hostsContent += "\n"
|
||||
}
|
||||
|
||||
hostsContent += entry + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Upload new /etc/hosts
|
||||
_, _ = client.Run(rwCmd)
|
||||
// Backup /etc/hosts if it doesn't exist
|
||||
if _, err := client.Run("[ -f /etc/hosts.original ]"); err != nil {
|
||||
_, _ = client.Run("cp /etc/hosts /etc/hosts.original")
|
||||
}
|
||||
|
||||
if err := client.UploadContent([]byte(hostsContent), "/etc/hosts"); err != nil {
|
||||
return fmt.Errorf("failed to update /etc/hosts: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Updated /etc/hosts on %s:\n%s\n", deviceIP, hostsContent)
|
||||
|
||||
// 4. Inject CA Certificate
|
||||
summary := &MigrationSummary{}
|
||||
m.checkCACertTrusted(summary, deviceIP)
|
||||
|
||||
if !summary.CACertTrusted {
|
||||
caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read CA certificate: %w", err)
|
||||
}
|
||||
|
||||
// Append to bundle
|
||||
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
|
||||
_, _ = client.Run(rwCmd)
|
||||
|
||||
// Backup bundle if it doesn't exist
|
||||
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", bundlePath)); err != nil {
|
||||
_, _ = client.Run(fmt.Sprintf("cp %s %s.original", bundlePath, bundlePath))
|
||||
}
|
||||
|
||||
// We use session.Run for append or similar, but client.Run uses CombinedOutput.
|
||||
// Let's use a temporary file and append it.
|
||||
tmpCertPath := "/tmp/local-ca.crt"
|
||||
if err := client.UploadContent(caCertPEM, tmpCertPath); err != nil {
|
||||
return fmt.Errorf("failed to upload CA cert to tmp: %w", err)
|
||||
}
|
||||
|
||||
if _, err := client.Run(fmt.Sprintf("%s && cat %s >> %s && rm %s", rwCmd, tmpCertPath, bundlePath, tmpCertPath)); err != nil {
|
||||
return fmt.Errorf("failed to append CA cert to bundle: %w", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("CA certificate already trusted on %s, skipping injection\n", deviceIP)
|
||||
}
|
||||
|
||||
// 5. Reboot
|
||||
if _, err := client.Run(fmt.Sprintf("%s && reboot", rwCmd)); err != nil {
|
||||
return fmt.Errorf("failed to reboot speaker: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveRemoteServices removes remote services from the device by deleting the known remote_services files.
|
||||
func (m *Manager) RemoveRemoteServices(deviceIP string) error {
|
||||
client := ssh.NewClient(deviceIP)
|
||||
client := m.NewSSH(deviceIP)
|
||||
rwCmd := "(rw || mount -o remount,rw /)"
|
||||
|
||||
locations := []string{
|
||||
@@ -481,3 +697,219 @@ func (m *Manager) RemoveRemoteServices(deviceIP string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestDomain is the fake domain used for preliminary redirection tests.
|
||||
const TestDomain = "custom-test-api.bose.fake"
|
||||
|
||||
// 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 /)"
|
||||
|
||||
// 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" {
|
||||
return "", fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
|
||||
}
|
||||
|
||||
hostIP := m.resolveIP(hostName, client)
|
||||
|
||||
testDomain := TestDomain
|
||||
testEntry := fmt.Sprintf("%s\t%s", hostIP, testDomain)
|
||||
|
||||
// 2. Add temporary entry to /etc/hosts
|
||||
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) {
|
||||
// Even if it's there, let's make sure it's correct (pointing to the current hostIP)
|
||||
// but for now, if it's there, we just assume it's okay or from a previous failed cleanup.
|
||||
// Let's remove it and re-add to be sure.
|
||||
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)
|
||||
// Ensure hostsContent ends with a newline if not empty
|
||||
if hostsContent != "" && !strings.HasSuffix(hostsContent, "\n") {
|
||||
hostsContent += "\n"
|
||||
}
|
||||
|
||||
newHostsContent := hostsContent + testEntry + "\n"
|
||||
if err := client.UploadContent([]byte(newHostsContent), "/etc/hosts"); err != nil {
|
||||
return "", fmt.Errorf("failed to add test entry to /etc/hosts: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Updated /etc/hosts on %s with test entry:\n%s\n", deviceIP, newHostsContent)
|
||||
|
||||
defer func() {
|
||||
// Clean up test entry
|
||||
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")
|
||||
}()
|
||||
|
||||
// 3. Test connection to the fake domain
|
||||
// 3a. HTTP (for simplicity of redirection test)
|
||||
// We use the health check endpoint on the same port but with the fake domain
|
||||
httpTestURL := fmt.Sprintf("http://%s:%s/health", testDomain, parsedURL.Port())
|
||||
if parsedURL.Port() == "" {
|
||||
httpTestURL = fmt.Sprintf("http://%s/health", testDomain)
|
||||
} else if parsedURL.Port() == "80" {
|
||||
httpTestURL = fmt.Sprintf("http://%s/health", testDomain)
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("curl -v -s -L %s", httpTestURL)
|
||||
|
||||
output, err := client.Run(cmd)
|
||||
if err != nil {
|
||||
return output, fmt.Errorf("hosts redirection HTTP test failed: %w", err)
|
||||
}
|
||||
|
||||
// 3b. HTTPS (to verify TLS reachability)
|
||||
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)
|
||||
}
|
||||
|
||||
// We now include the testDomain in our SSL certificate.
|
||||
// We use the local CA certificate to verify the connection.
|
||||
caPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
|
||||
if err != nil {
|
||||
return output, 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 output, fmt.Errorf("failed to upload temporary CA for HTTPS test: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_, _ = client.Run("rm " + caPath)
|
||||
}()
|
||||
|
||||
httpsCmd := fmt.Sprintf("curl -v -s -L --cacert %s %s", caPath, httpsTestURL)
|
||||
|
||||
httpsOutput, httpsErr := client.Run(httpsCmd)
|
||||
if httpsErr != nil {
|
||||
return output + "\n---\n" + httpsOutput, fmt.Errorf("hosts redirection HTTPS test failed: %w", httpsErr)
|
||||
}
|
||||
|
||||
return output + "\n---\n" + httpsOutput, nil
|
||||
}
|
||||
|
||||
// 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 -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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -4,10 +4,110 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/crypto"
|
||||
)
|
||||
|
||||
type mockSSH struct {
|
||||
runFunc func(command string) (string, error)
|
||||
uploadContentFunc func(content []byte, remotePath string) error
|
||||
}
|
||||
|
||||
func (m *mockSSH) Run(command string) (string, error) {
|
||||
if m.runFunc != nil {
|
||||
return m.runFunc(command)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *mockSSH) UploadContent(content []byte, remotePath string) error {
|
||||
if m.uploadContentFunc != nil {
|
||||
return m.uploadContentFunc(content, remotePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestMigrateViaHosts(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "setup-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cm := crypto.NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
t.Fatalf("Failed to ensure CA: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager("http://192.168.1.100:8000", nil, cm)
|
||||
|
||||
runCalls := []string{}
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
runCalls = append(runCalls, command)
|
||||
if command == "cat /etc/hosts" {
|
||||
return "127.0.0.1 localhost", nil
|
||||
}
|
||||
if strings.HasPrefix(command, "[ -f") {
|
||||
return "", fmt.Errorf("file not found")
|
||||
}
|
||||
if strings.HasPrefix(command, "grep -F") {
|
||||
return "", fmt.Errorf("not found")
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
uploadContentFunc: func(content []byte, remotePath string) error {
|
||||
if remotePath == "/etc/hosts" {
|
||||
if !strings.Contains(string(content), "192.168.1.100\tstreaming.bose.com") {
|
||||
t.Errorf("Expected hosts content to contain redirect, got %s", string(content))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
err = m.migrateViaHosts("192.168.1.10", "http://192.168.1.100:8000")
|
||||
if err != nil {
|
||||
t.Fatalf("migrateViaHosts failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify backups were attempted
|
||||
foundHostsBackup := false
|
||||
foundBundleBackup := false
|
||||
for _, call := range runCalls {
|
||||
if strings.Contains(call, "cp /etc/hosts /etc/hosts.original") {
|
||||
foundHostsBackup = true
|
||||
}
|
||||
if strings.Contains(call, "cp /etc/pki/tls/certs/ca-bundle.crt /etc/pki/tls/certs/ca-bundle.crt.original") {
|
||||
foundBundleBackup = true
|
||||
}
|
||||
}
|
||||
if !foundHostsBackup {
|
||||
t.Errorf("Expected /etc/hosts backup to be attempted")
|
||||
}
|
||||
if !foundBundleBackup {
|
||||
t.Errorf("Expected ca-bundle.crt backup to be attempted")
|
||||
}
|
||||
|
||||
// Verify reboot was called
|
||||
foundReboot := false
|
||||
for _, call := range runCalls {
|
||||
if strings.Contains(call, "reboot") {
|
||||
foundReboot = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundReboot {
|
||||
t.Errorf("Expected reboot to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLiveDeviceInfo(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/info" {
|
||||
@@ -34,7 +134,7 @@ func TestGetLiveDeviceInfo(t *testing.T) {
|
||||
// The test server URL is like http://127.0.0.1:54321
|
||||
host := server.Listener.Addr().String()
|
||||
|
||||
manager := NewManager("http://localhost:8000", nil)
|
||||
manager := NewManager("http://localhost:8000", nil, nil)
|
||||
|
||||
info, err := manager.GetLiveDeviceInfo(host)
|
||||
if err != nil {
|
||||
@@ -58,7 +158,7 @@ func TestGetMigrationSummary_SSHFailure(t *testing.T) {
|
||||
// Use an IP that is unlikely to have an SSH server running or reachable
|
||||
// or use a local port that is closed.
|
||||
// We'll use a local port that we know is closed.
|
||||
manager := NewManager("http://localhost:8000", nil)
|
||||
manager := NewManager("http://localhost:8000", nil, nil)
|
||||
summary, err := manager.GetMigrationSummary("127.0.0.1", "", "", nil)
|
||||
|
||||
// Currently it might return an error OR it might return a summary with SSHSuccess: false
|
||||
@@ -86,7 +186,7 @@ func TestGetMigrationSummary_WithProxyOptions(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
host := server.Listener.Addr().String()
|
||||
manager := NewManager("http://soundcork:8000", nil)
|
||||
manager := NewManager("http://soundcork:8000", nil, nil)
|
||||
|
||||
// Since we can't easily mock SSH here without a full SSH server,
|
||||
// we are testing the logic that depends on ParsedCurrentConfig being nil or not.
|
||||
@@ -109,6 +209,347 @@ func TestGetMigrationSummary_WithProxyOptions(t *testing.T) {
|
||||
if !contains(summary.PlannedConfig, "http://target:8000/marge") {
|
||||
t.Errorf("Expected default marge URL when SSH fails, got: %s", summary.PlannedConfig)
|
||||
}
|
||||
|
||||
// Test PlannedHosts
|
||||
if !contains(summary.PlannedHosts, "target\tstreaming.bose.com") {
|
||||
t.Errorf("Expected PlannedHosts to contain redirect for target, got: %s", summary.PlannedHosts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCACertTrusted(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "ca-trust-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cm := crypto.NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
t.Fatalf("Failed to ensure CA: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager("http://localhost:8000", nil, cm)
|
||||
|
||||
// Mock SSH to return "found" for grep
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if strings.HasPrefix(command, "grep -F") {
|
||||
return "found", nil
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
summary := &MigrationSummary{}
|
||||
m.checkCACertTrusted(summary, "192.168.1.10")
|
||||
|
||||
if !summary.CACertTrusted {
|
||||
t.Errorf("Expected CACertTrusted to be true, got false")
|
||||
}
|
||||
|
||||
// Mock SSH to return "not found" (error) for grep
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if strings.HasPrefix(command, "grep -F") {
|
||||
return "", fmt.Errorf("not found")
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
summary = &MigrationSummary{}
|
||||
m.checkCACertTrusted(summary, "192.168.1.10")
|
||||
|
||||
if summary.CACertTrusted {
|
||||
t.Errorf("Expected CACertTrusted to be false, got true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestConnection(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "test-connection")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cm := crypto.NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
t.Fatalf("Failed to ensure CA: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager("http://localhost:8000", nil, cm)
|
||||
|
||||
runCalls := []string{}
|
||||
uploadCalls := []string{}
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
runCalls = append(runCalls, command)
|
||||
if strings.Contains(command, "curl") {
|
||||
return "HTTP/1.1 200 OK", nil
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
uploadContentFunc: func(content []byte, remotePath string) error {
|
||||
uploadCalls = append(uploadCalls, remotePath)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Test 1: Shared trust store (no explicit CA)
|
||||
output, err := m.TestConnection("192.168.1.10", "https://localhost:8443/health", false)
|
||||
if err != nil {
|
||||
t.Fatalf("TestConnection failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(output, "200 OK") {
|
||||
t.Errorf("Expected output to contain '200 OK', got %s", output)
|
||||
}
|
||||
if len(uploadCalls) != 0 {
|
||||
t.Errorf("Expected no uploads for shared trust store test, got %v", uploadCalls)
|
||||
}
|
||||
|
||||
// Test 2: Explicit CA
|
||||
output, err = m.TestConnection("192.168.1.10", "https://localhost:8443/health", true)
|
||||
if err != nil {
|
||||
t.Fatalf("TestConnection failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(output, "200 OK") {
|
||||
t.Errorf("Expected output to contain '200 OK', got %s", output)
|
||||
}
|
||||
foundUpload := false
|
||||
for _, path := range uploadCalls {
|
||||
if path == "/tmp/soundtouch-test-ca.crt" {
|
||||
foundUpload = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundUpload {
|
||||
t.Errorf("Expected CA to be uploaded to /tmp/soundtouch-test-ca.crt")
|
||||
}
|
||||
|
||||
foundCurlWithCA := false
|
||||
for _, call := range runCalls {
|
||||
if strings.Contains(call, "curl") && strings.Contains(call, "--cacert /tmp/soundtouch-test-ca.crt") {
|
||||
foundCurlWithCA = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundCurlWithCA {
|
||||
t.Errorf("Expected curl command to use --cacert")
|
||||
}
|
||||
|
||||
// Verify cleanup
|
||||
foundRm := false
|
||||
for _, call := range runCalls {
|
||||
if call == "rm /tmp/soundtouch-test-ca.crt" {
|
||||
foundRm = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundRm {
|
||||
t.Errorf("Expected cleanup command 'rm /tmp/soundtouch-test-ca.crt' to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestHostsRedirection(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "hosts-redirection-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cm := crypto.NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
t.Fatalf("Failed to ensure CA: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager("http://localhost:8000", nil, cm)
|
||||
|
||||
runCalls := []string{}
|
||||
uploadCalls := []string{}
|
||||
var currentHostsContent = "127.0.0.1 localhost\n"
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
mock := &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
runCalls = append(runCalls, command)
|
||||
if command == "cat /etc/hosts" {
|
||||
return currentHostsContent, nil
|
||||
}
|
||||
if strings.Contains(command, "curl") {
|
||||
return "HTTP/1.1 200 OK", nil
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
uploadContentFunc: func(content []byte, remotePath string) error {
|
||||
uploadCalls = append(uploadCalls, remotePath)
|
||||
if remotePath == "/etc/hosts" {
|
||||
currentHostsContent = string(content)
|
||||
if strings.Contains(string(content), "custom-test-api.bose.fake") {
|
||||
if !strings.Contains(string(content), "1.2.3.4\tcustom-test-api.bose.fake") {
|
||||
t.Errorf("Expected hosts content to contain test redirect with IP 1.2.3.4, got %s", string(content))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return mock
|
||||
}
|
||||
|
||||
output, err := m.TestHostsRedirection("192.168.1.10", "http://1.2.3.4:8000")
|
||||
if err != nil {
|
||||
t.Fatalf("TestHostsRedirection failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "200 OK") {
|
||||
t.Errorf("Expected output to contain '200 OK', got %s", output)
|
||||
}
|
||||
|
||||
// Verify upload of test hosts
|
||||
foundHostsUpload := false
|
||||
foundCAUpload := false
|
||||
for _, path := range uploadCalls {
|
||||
if path == "/etc/hosts" {
|
||||
foundHostsUpload = true
|
||||
}
|
||||
if path == "/tmp/soundtouch-test-ca.crt" {
|
||||
foundCAUpload = true
|
||||
}
|
||||
}
|
||||
if !foundHostsUpload {
|
||||
t.Errorf("Expected /etc/hosts to be uploaded")
|
||||
}
|
||||
if !foundCAUpload {
|
||||
t.Errorf("Expected CA to be uploaded to /tmp/soundtouch-test-ca.crt")
|
||||
}
|
||||
|
||||
// Verify curl calls for both HTTP and HTTPS
|
||||
foundHTTP := false
|
||||
foundHTTPSWithCA := false
|
||||
for _, call := range runCalls {
|
||||
if strings.Contains(call, "curl") {
|
||||
if strings.Contains(call, "http://") {
|
||||
foundHTTP = true
|
||||
}
|
||||
if strings.Contains(call, "https://") && strings.Contains(call, "--cacert /tmp/soundtouch-test-ca.crt") {
|
||||
foundHTTPSWithCA = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundHTTP {
|
||||
t.Errorf("Expected HTTP curl call")
|
||||
}
|
||||
if !foundHTTPSWithCA {
|
||||
t.Errorf("Expected HTTPS curl call with --cacert")
|
||||
}
|
||||
|
||||
// Verify cleanup
|
||||
foundRmCA := false
|
||||
for _, call := range runCalls {
|
||||
if call == "rm /tmp/soundtouch-test-ca.crt" {
|
||||
foundRmCA = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundRmCA {
|
||||
t.Errorf("Expected cleanup command 'rm /tmp/soundtouch-test-ca.crt' to be called")
|
||||
}
|
||||
|
||||
cleanupHostsCount := 0
|
||||
for _, path := range uploadCalls {
|
||||
if path == "/etc/hosts" {
|
||||
cleanupHostsCount++
|
||||
}
|
||||
}
|
||||
if cleanupHostsCount < 2 {
|
||||
t.Errorf("Expected at least 2 uploads to /etc/hosts (one for test, one for cleanup), got %d", cleanupHostsCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIP(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
// Test with IP
|
||||
if m.resolveIP("1.2.3.4", nil) != "1.2.3.4" {
|
||||
t.Errorf("Expected 1.2.3.4, got %s", m.resolveIP("1.2.3.4", nil))
|
||||
}
|
||||
|
||||
// Test with localhost
|
||||
if m.resolveIP("localhost", nil) != "127.0.0.1" && m.resolveIP("localhost", nil) != "::1" {
|
||||
t.Errorf("Expected localhost resolution, got %s", m.resolveIP("localhost", nil))
|
||||
}
|
||||
|
||||
// Test with device resolution (mocked)
|
||||
mock := &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if strings.Contains(command, "ping -c 1 myhost") {
|
||||
return "PING myhost (10.0.0.5): 56 data bytes", nil
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
if m.resolveIP("myhost", mock) != "10.0.0.5" {
|
||||
t.Errorf("Expected 10.0.0.5 from device, got %s", m.resolveIP("myhost", mock))
|
||||
}
|
||||
|
||||
// Test with non-existent host (should fallback to input)
|
||||
if m.resolveIP("non-existent.host.fake", nil) != "non-existent.host.fake" {
|
||||
t.Errorf("Expected fallback to input, got %s", m.resolveIP("non-existent.host.fake", nil))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateViaHosts_SkipCAIfTrusted(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "setup-test-skip-ca")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cm := crypto.NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
t.Fatalf("Failed to ensure CA: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager("http://192.168.1.100:8000", nil, cm)
|
||||
|
||||
runCalls := []string{}
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
runCalls = append(runCalls, command)
|
||||
if command == "cat /etc/hosts" {
|
||||
return "127.0.0.1 localhost", nil
|
||||
}
|
||||
if strings.HasPrefix(command, "grep -F") {
|
||||
// Simulate CA already trusted
|
||||
return "found", nil
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
err = m.migrateViaHosts("192.168.1.10", "http://192.168.1.100:8000")
|
||||
if err != nil {
|
||||
t.Fatalf("migrateViaHosts failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify CA injection was skipped
|
||||
foundCAInjection := false
|
||||
for _, call := range runCalls {
|
||||
if strings.Contains(call, "cat /tmp/local-ca.crt >> /etc/pki/tls/certs/ca-bundle.crt") {
|
||||
foundCAInjection = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if foundCAInjection {
|
||||
t.Errorf("Expected CA injection to be skipped when already trusted")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
|
||||
Reference in New Issue
Block a user