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:
Tobias Gesellchen
2026-02-12 23:41:54 +01:00
parent 0186fead6e
commit 00d5bfcb69
19 changed files with 2358 additions and 534 deletions
+261
View File
@@ -0,0 +1,261 @@
package crypto
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"time"
)
// CertificateManager handles CA and certificate generation.
type CertificateManager struct {
CertsDir string
}
// NewCertificateManager creates a new CertificateManager.
func NewCertificateManager(certsDir string) *CertificateManager {
return &CertificateManager{CertsDir: certsDir}
}
// GetCACertPath returns the path to the CA certificate.
func (cm *CertificateManager) GetCACertPath() string {
return filepath.Join(cm.CertsDir, "ca.crt")
}
// GetCAKeyPath returns the path to the CA private key.
func (cm *CertificateManager) GetCAKeyPath() string {
return filepath.Join(cm.CertsDir, "ca.key")
}
// EnsureCA ensures that a CA certificate and key exist.
func (cm *CertificateManager) EnsureCA() error {
certPath := cm.GetCACertPath()
keyPath := cm.GetCAKeyPath()
if _, err := os.Stat(certPath); err == nil {
if _, err := os.Stat(keyPath); err == nil {
return nil
}
}
return cm.GenerateCA()
}
// GetServerCertPEMPath returns the path to the server certificate PEM.
func (cm *CertificateManager) GetServerCertPEMPath() string {
return filepath.Join(cm.CertsDir, "server.crt")
}
// GetServerKeyPEMPath returns the path to the server private key PEM.
func (cm *CertificateManager) GetServerKeyPEMPath() string {
return filepath.Join(cm.CertsDir, "server.key")
}
// GetServerTLSConfig returns a TLS config with the server certificate.
// If the certificate doesn't exist, it generates one for the given domains.
func (cm *CertificateManager) GetServerTLSConfig(domains []string) (*tls.Config, error) {
certPath := cm.GetServerCertPEMPath()
keyPath := cm.GetServerKeyPEMPath()
generate := false
if _, err := os.Stat(certPath); os.IsNotExist(err) {
generate = true
} else {
// Check if the current certificate covers all requested domains
certBytes, err := os.ReadFile(certPath)
if err == nil {
block, _ := pem.Decode(certBytes)
if block != nil {
cert, err := x509.ParseCertificate(block.Bytes)
if err == nil {
domainMap := make(map[string]bool)
for _, d := range cert.DNSNames {
domainMap[d] = true
}
for _, d := range domains {
if !domainMap[d] {
generate = true
break
}
}
} else {
generate = true
}
} else {
generate = true
}
} else {
generate = true
}
}
if generate {
certPEM, keyPEM, err := cm.GenerateCertificate(domains)
if err != nil {
return nil, err
}
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
return nil, err
}
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
return nil, err
}
}
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, err
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
CipherSuites: []uint16{
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
},
}, nil
}
// GenerateCA generates a new CA certificate and key.
func (cm *CertificateManager) GenerateCA() error {
priv, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return err
}
notBefore := time.Now()
notAfter := notBefore.Add(10 * 365 * 24 * time.Hour) // 10 years
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return err
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"SoundTouch Local Service"},
CommonName: "SoundTouch Local Root CA",
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IsCA: true,
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return err
}
certPath := cm.GetCACertPath()
if err := os.MkdirAll(cm.CertsDir, 0755); err != nil {
return err
}
certOut, err := os.Create(certPath)
if err != nil {
return err
}
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
return err
}
certOut.Close()
keyOut, err := os.OpenFile(cm.GetCAKeyPath(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}); err != nil {
return err
}
keyOut.Close()
return nil
}
// GenerateCertificate generates a certificate for the given domains signed by the CA.
func (cm *CertificateManager) GenerateCertificate(domains []string) ([]byte, []byte, error) {
if err := cm.EnsureCA(); err != nil {
return nil, nil, err
}
caCertPEM, err := os.ReadFile(cm.GetCACertPath())
if err != nil {
return nil, nil, err
}
caKeyPEM, err := os.ReadFile(cm.GetCAKeyPath())
if err != nil {
return nil, nil, err
}
caBlock, _ := pem.Decode(caCertPEM)
caCert, err := x509.ParseCertificate(caBlock.Bytes)
if err != nil {
return nil, nil, err
}
keyBlock, _ := pem.Decode(caKeyPEM)
caKey, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
if err != nil {
return nil, nil, err
}
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, nil, err
}
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * time.Hour) // 1 year
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, nil, err
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"SoundTouch Local Service"},
CommonName: domains[0],
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: domains,
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, caCert, &priv.PublicKey, caKey)
if err != nil {
return nil, nil, err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
return certPEM, keyPEM, nil
}
+128
View File
@@ -0,0 +1,128 @@
package crypto
import (
"crypto/x509"
"encoding/pem"
"os"
"path/filepath"
"testing"
)
func TestCertificateManager(t *testing.T) {
tempDir, err := os.MkdirTemp("", "crypto-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
cm := NewCertificateManager(filepath.Join(tempDir, "certs"))
// Test CA generation
if err := cm.EnsureCA(); err != nil {
t.Fatalf("Failed to ensure CA: %v", err)
}
if _, err := os.Stat(cm.GetCACertPath()); os.IsNotExist(err) {
t.Errorf("CA certificate not created")
}
if _, err := os.Stat(cm.GetCAKeyPath()); os.IsNotExist(err) {
t.Errorf("CA key not created")
}
// Test loading CA
caCertPEM, err := os.ReadFile(cm.GetCACertPath())
if err != nil {
t.Fatalf("Failed to read CA cert: %v", err)
}
block, _ := pem.Decode(caCertPEM)
if block == nil || block.Type != "CERTIFICATE" {
t.Errorf("Invalid CA certificate PEM")
}
caCert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("Failed to parse CA cert: %v", err)
}
if !caCert.IsCA {
t.Errorf("Generated certificate is not a CA")
}
// Test certificate generation
domains := []string{"streaming.bose.com", "updates.bose.com"}
certPEM, keyPEM, err := cm.GenerateCertificate(domains)
if err != nil {
t.Fatalf("Failed to generate certificate: %v", err)
}
if len(certPEM) == 0 || len(keyPEM) == 0 {
t.Errorf("Generated certificate or key is empty")
}
// Verify generated certificate
block, _ = pem.Decode(certPEM)
if block == nil || block.Type != "CERTIFICATE" {
t.Errorf("Invalid certificate PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("Failed to parse certificate: %v", err)
}
if cert.Subject.CommonName != domains[0] {
t.Errorf("Expected CommonName %s, got %s", domains[0], cert.Subject.CommonName)
}
// Check DNS names
if len(cert.DNSNames) != len(domains) {
t.Errorf("Expected %d DNS names, got %d", len(domains), len(cert.DNSNames))
}
// Verify against CA
roots := x509.NewCertPool()
roots.AddCert(caCert)
opts := x509.VerifyOptions{
DNSName: domains[0],
Roots: roots,
}
if _, err := cert.Verify(opts); err != nil {
t.Errorf("Failed to verify certificate against CA: %v", err)
}
// Test GetServerTLSConfig
tlsConfig, err := cm.GetServerTLSConfig(domains)
if err != nil {
t.Fatalf("Failed to get TLS config: %v", err)
}
if tlsConfig == nil {
t.Fatal("TLS config is nil")
}
if len(tlsConfig.Certificates) == 0 {
t.Fatal("TLS config has no certificates")
}
// Test certificate regeneration if domains change
newDomains := append(domains, "mac.fritz.box")
tlsConfig2, err := cm.GetServerTLSConfig(newDomains)
if err != nil {
t.Fatalf("Failed to get updated TLS config: %v", err)
}
if len(tlsConfig2.Certificates[0].Leaf.DNSNames) < 3 {
// Note: tls.LoadX509KeyPair doesn't populate Leaf by default.
// We should parse it manually or rely on the file existence/content.
certBytes, _ := os.ReadFile(cm.GetServerCertPEMPath())
block, _ := pem.Decode(certBytes)
cert, _ := x509.ParseCertificate(block.Bytes)
found := false
for _, d := range cert.DNSNames {
if d == "mac.fritz.box" {
found = true
break
}
}
if !found {
t.Errorf("Regenerated certificate does not contain new domain")
}
}
}
+12 -1
View File
@@ -8,9 +8,12 @@ import (
"strings"
)
//go:embed index.html
//go:embed web/index.html
var indexHTML []byte
//go:embed web/css/* web/js/*
var webFS embed.FS
//go:embed soundcork/media/*
var mediaFS embed.FS
@@ -34,6 +37,14 @@ func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(indexHTML)
}
// HandleWeb returns a handler for serving web resources.
func (s *Server) HandleWeb() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fs := http.FileServer(http.FS(webFS))
fs.ServeHTTP(w, r)
}
}
// HandleMedia returns a handler for serving media files.
func (s *Server) HandleMedia() http.HandlerFunc {
subFS, _ := fs.Sub(mediaFS, "soundcork/media")
@@ -97,3 +97,39 @@ func TestStaticMedia(t *testing.T) {
t.Errorf("Expected image/svg+xml content type, got %s", contentType)
}
}
func TestStaticWeb(t *testing.T) {
r, _ := setupRouter("http://localhost:8001", nil)
ts := httptest.NewServer(r)
defer ts.Close()
// 1. Test CSS
res, err := http.Get(ts.URL + "/web/css/style.css")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("CSS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
t.Errorf("CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
}
// 2. Test JS
res, err = http.Get(ts.URL + "/web/js/script.js")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("JS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
t.Errorf("JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
}
}
+90 -1
View File
@@ -3,7 +3,9 @@ package handlers
import (
"encoding/json"
"net/http"
"os"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/go-chi/chi/v5"
)
@@ -126,6 +128,7 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
targetURL := r.URL.Query().Get("target_url")
proxyURL := r.URL.Query().Get("proxy_url")
method := setup.MigrationMethod(r.URL.Query().Get("method"))
options := make(map[string]string)
@@ -135,7 +138,7 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
}
}
if err := s.sm.MigrateSpeaker(deviceIP, targetURL, proxyURL, options); err != nil {
if err := s.sm.MigrateSpeaker(deviceIP, targetURL, proxyURL, options, method); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
@@ -273,6 +276,21 @@ func (s *Server) HandleGetProxySettings(w http.ResponseWriter, _ *http.Request)
}
}
// HandleGetCACert returns the Root CA certificate.
func (s *Server) HandleGetCACert(w http.ResponseWriter, _ *http.Request) {
caCertPath := s.sm.Crypto.GetCACertPath()
content, err := os.ReadFile(caCertPath)
if err != nil {
http.Error(w, "Failed to read CA certificate", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/x-x509-ca-cert")
w.Header().Set("Content-Disposition", "attachment; filename=soundtouch-ca.crt")
_, _ = w.Write(content)
}
// HandleUpdateProxySettings updates the proxy settings.
func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Request) {
var settings struct {
@@ -294,3 +312,74 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques
return
}
}
// HandleTestHostsRedirection performs a preliminary check for /etc/hosts redirection.
func (s *Server) HandleTestHostsRedirection(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
http.Error(w, "Device IP is required", http.StatusBadRequest)
return
}
targetURL := r.URL.Query().Get("target_url")
if targetURL == "" {
targetURL = s.serverURL
}
output, err := s.sm.TestHostsRedirection(deviceIP, targetURL)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) // Return 200 but ok: false so UI can show the output
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": false,
"message": err.Error(),
"output": output,
})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"message": "Hosts redirection test successful",
"output": output,
})
}
// HandleTestConnection performs a connection check from the device to the server.
func (s *Server) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
http.Error(w, "Device IP is required", http.StatusBadRequest)
return
}
targetURL := r.URL.Query().Get("target_url")
if targetURL == "" {
http.Error(w, "Target URL is required", http.StatusBadRequest)
return
}
useExplicitCA := r.URL.Query().Get("use_explicit_ca") == "true"
output, err := s.sm.TestConnection(deviceIP, targetURL, useExplicitCA)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) // Return 200 but ok: false so UI can show the output
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": false,
"message": err.Error(),
"output": output,
})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"message": "Connection test successful",
"output": output,
})
}
@@ -5,7 +5,13 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/crypto"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
func TestProxySettingsAPI(t *testing.T) {
@@ -81,3 +87,71 @@ func TestProxySettingsAPI(t *testing.T) {
t.Errorf("GET (after update): Unexpected settings: %+v", settings)
}
}
func TestMigrationAndCA(t *testing.T) {
tempDir, err := os.MkdirTemp("", "handlers-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
cm := crypto.NewCertificateManager(filepath.Join(tempDir, "certs"))
_ = cm.EnsureCA()
sm := setup.NewManager("http://localhost:8000", ds, cm)
// Mock SSH to avoid real connections
sm.NewSSH = func(host string) setup.SSHClient {
return &mockSSH{}
}
r, server := setupRouter("http://localhost:8001", ds)
server.sm = sm // Inject our manager with mock SSH
ts := httptest.NewServer(r)
defer ts.Close()
// 1. Test GET /setup/ca.crt
res, err := http.Get(ts.URL + "/setup/ca.crt")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("CA: Expected status OK, got %v", res.Status)
}
if res.Header.Get("Content-Type") != "application/x-x509-ca-cert" {
t.Errorf("CA: Unexpected content type: %s", res.Header.Get("Content-Type"))
}
// 2. Test POST /setup/migrate/{deviceIP}?method=hosts
res, err = http.Post(ts.URL+"/setup/migrate/192.168.1.10?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Migrate: Expected status OK, got %v", res.Status)
}
var result map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
t.Fatalf("Migrate: Failed to decode response: %v", err)
}
if result["ok"] != true {
t.Errorf("Migrate: Expected ok=true, got %v", result["ok"])
}
}
type mockSSH struct{}
func (m *mockSSH) Run(command string) (string, error) {
if command == "cat /etc/hosts" {
return "127.0.0.1 localhost", nil
}
return "", nil
}
func (m *mockSSH) UploadContent(content []byte, remotePath string) error { return nil }
-515
View File
@@ -1,515 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Soundcork Management</title>
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
<style>
body { font-family: sans-serif; margin: 20px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
button { padding: 5px 10px; cursor: pointer; }
.status { margin-top: 10px; padding: 10px; border: 1px solid #ccc; display: none; }
.summary-box { margin-top: 20px; padding: 15px; border: 1px solid #aaa; background-color: #f9f9f9; display: none; }
pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px; }
.diff-container { display: flex; gap: 10px; }
.diff-pane { flex: 1; min-width: 0; }
.config-header { font-weight: bold; margin-bottom: 5px; display: block; }
</style>
</head>
<body>
<h1>Soundcork Management</h1>
<h2>Discovered Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
<div id="device-list">Loading devices...</div>
<div id="manual-entry" style="margin-top: 20px; border-top: 1px solid #eee; padding-top: 10px;">
<h3>Manual Entry</h3>
<input type="text" id="manual-ip" placeholder="Device IP (e.g. 192.168.1.100)">
<button onclick="showSummary(document.getElementById('manual-ip').value)">Check Migration</button>
<h3 style="margin-top: 20px;">Settings</h3>
<div style="margin-bottom: 10px;">
<label for="target-domain">Target Domain:</label>
<input type="text" id="target-domain" placeholder="http://localhost:8000" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(This URL will be used for standard services)</span>
</div>
<div style="margin-bottom: 10px;">
<label for="proxy-domain">Proxy Domain:</label>
<input type="text" id="proxy-domain" placeholder="http://localhost:8000" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(This URL will be used to proxy upstream Bose services)</span>
</div>
<div style="margin-bottom: 10px;">
Proxy Logging:
<label><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
<label style="margin-left: 15px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
</div>
</div>
<div id="status" class="status"></div>
<div id="migration-summary" class="summary-box">
<h3>Migration Summary for <span id="summary-ip"></span></h3>
<p>SSH Connection: <span id="ssh-status"></span></p>
<p id="original-config-status" style="display: none;">Backup: ✅ Found .original config at <code>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code> <button onclick="toggleOriginalConfig()">Show Original Config</button></p>
<p id="no-original-config-status" style="display: none;">Backup: ❌ Not found <button id="backup-config-btn">Backup Config Now</button></p>
<p>Remote Services Enabled: <span id="remote-services-status"></span> <span id="remote-services-found" style="font-size: 0.8em; color: #666;"></span></p>
<div id="original-config-pane" style="display: none; margin-bottom: 20px;">
<span class="config-header">Original Config (Backup)</span>
<pre id="original-config-content"></pre>
</div>
<div id="service-options" style="margin-bottom: 20px; display: none;">
<h4>Service Implementations</h4>
<table>
<tr><th>Service</th><th>Original URL</th><th>Implementation</th></tr>
<tr>
<td>Marge (Streaming)</td>
<td id="orig-marge">loading...</td>
<td>
<select id="opt-marge" onchange="refreshSummary()">
<option value="soundcork">Soundcork (Go/Python)</option>
<option value="original">Original (Proxy via soundcork-go)</option>
</select>
</td>
</tr>
<tr>
<td>Stats</td>
<td id="orig-stats">loading...</td>
<td>
<select id="opt-stats" onchange="refreshSummary()">
<option value="soundcork">Soundcork (Go/Python)</option>
<option value="original">Original (Proxy via soundcork-go)</option>
</select>
</td>
</tr>
<tr>
<td>Software Update</td>
<td id="orig-sw_update">loading...</td>
<td>
<select id="opt-sw_update" onchange="refreshSummary()">
<option value="soundcork">Soundcork (Go/Python)</option>
<option value="original">Original (Proxy via soundcork-go)</option>
</select>
</td>
</tr>
<tr>
<td>BMX (Registry)</td>
<td id="orig-bmx">loading...</td>
<td>
<select id="opt-bmx" onchange="refreshSummary()">
<option value="soundcork">Soundcork (Go/Python)</option>
<option value="original">Original (Proxy via soundcork-go)</option>
</select>
</td>
</tr>
</table>
</div>
<div class="diff-container">
<div class="diff-pane">
<span class="config-header">Current Config (on Speaker)</span>
<pre id="current-config"></pre>
</div>
<div class="diff-pane">
<span class="config-header">Planned Config (Soundcork)</span>
<pre id="planned-config"></pre>
</div>
</div>
<div style="margin-top: 15px;">
<button id="confirm-migrate-btn" style="background-color: #4CAF50; color: white; border: none; padding: 10px 20px;">Confirm Migration & Reboot</button>
<button id="ensure-remote-btn" style="background-color: #2196F3; color: white; border: none; padding: 10px 20px;">Enable Persistent Remote Services</button>
<button id="remove-remote-btn" style="background-color: #f44336; color: white; border: none; padding: 10px 20px;">Remove Persistent Remote Services</button>
<button onclick="document.getElementById('migration-summary').style.display='none'" style="padding: 10px 20px;">Cancel</button>
</div>
</div>
<script>
async function fetchSettings() {
try {
const response = await fetch('/setup/settings');
const settings = await response.json();
if (settings.server_url) {
document.getElementById('target-domain').value = settings.server_url;
}
if (settings.proxy_url) {
document.getElementById('proxy-domain').value = settings.proxy_url;
}
fetchProxySettings();
} catch (error) {
console.error('Failed to fetch settings', error);
}
}
async function fetchProxySettings() {
try {
const response = await fetch('/setup/proxy-settings');
const settings = await response.json();
document.getElementById('proxy-redact').checked = settings.redact;
document.getElementById('proxy-log-body').checked = settings.log_body;
} catch (error) {
console.error('Failed to fetch proxy settings', error);
}
}
async function updateProxySettings() {
const settings = {
redact: document.getElementById('proxy-redact').checked,
log_body: document.getElementById('proxy-log-body').checked
};
try {
await fetch('/setup/proxy-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
} catch (error) {
console.error('Failed to update proxy settings', error);
}
}
async function fetchDevices() {
try {
const response = await fetch('/setup/devices');
const devices = await response.json();
const container = document.getElementById('device-list');
if (devices.length === 0) {
container.innerHTML = 'No devices found.';
} else {
let html = '<table><tr><th>Name</th><th>IP Address</th><th>Model</th><th>Serial Number</th><th>Firmware</th><th>Action</th></tr>';
devices.forEach(d => {
html += `
<tr id="device-row-${d.ip_address.replace(/\./g, '-')}">
<td class="col-name">${d.name}</td>
<td class="col-ip">${d.ip_address}</td>
<td class="col-model">${d.product_code}</td>
<td class="col-serial">${d.device_serial_number}</td>
<td class="col-firmware">${d.firmware_version || '0.0.0'}</td>
<td><button onclick="showSummary('${d.ip_address}')">Prepare Migration</button></td>
</tr>
`;
});
html += '</table>';
container.innerHTML = html;
// Asynchronously fetch live info for each device
devices.forEach(d => updateDeviceInfo(d.ip_address));
}
} catch (error) {
document.getElementById('device-list').innerHTML = 'Error loading devices: ' + error;
}
}
async function triggerDiscovery() {
const indicator = document.getElementById('discovery-indicator');
indicator.style.display = 'inline';
try {
await fetch('/setup/discover', { method: 'POST' });
pollDiscoveryStatus();
} catch (error) {
console.error('Failed to trigger discovery', error);
indicator.style.display = 'none';
}
}
async function pollDiscoveryStatus() {
const indicator = document.getElementById('discovery-indicator');
try {
const response = await fetch('/setup/discovery-status');
const data = await response.json();
if (data.discovering) {
setTimeout(pollDiscoveryStatus, 2000);
} else {
indicator.style.display = 'none';
fetchDevices();
}
} catch (error) {
console.error('Failed to check discovery status', error);
indicator.style.display = 'none';
}
}
async function updateDeviceInfo(ip) {
try {
const response = await fetch('/setup/info/' + ip);
if (!response.ok) return;
const info = await response.json();
const rowId = 'device-row-' + ip.replace(/\./g, '-');
const row = document.getElementById(rowId);
if (row) {
if (info.name) row.querySelector('.col-name').innerText = info.name;
if (info.type) row.querySelector('.col-model').innerText = info.type;
if (info.serialNumber) row.querySelector('.col-serial').innerText = info.serialNumber;
if (info.softwareVersion) row.querySelector('.col-firmware').innerText = info.softwareVersion;
}
} catch (error) {
console.warn('Failed to fetch live info for ' + ip, error);
}
}
async function showSummary(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
return;
}
const targetUrl = document.getElementById('target-domain').value;
const proxyUrl = document.getElementById('proxy-domain').value;
const opts = {
marge: document.getElementById('opt-marge').value,
stats: document.getElementById('opt-stats').value,
sw_update: document.getElementById('opt-sw_update').value,
bmx: document.getElementById('opt-bmx').value
};
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Fetching summary for ' + ip + '...';
let query = '?target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
for (let k in opts) {
query += '&' + k + '=' + encodeURIComponent(opts[k]);
}
try {
const response = await fetch('/setup/summary/' + ip + query);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText);
}
const summary = await response.json();
statusDiv.style.display = 'none';
document.getElementById('summary-ip').innerText = ip;
// Update table row if it exists
const rowId = 'device-row-' + ip.replace(/\./g, '-');
const row = document.getElementById(rowId);
if (row) {
if (summary.device_name) row.querySelector('.col-name').innerText = summary.device_name;
if (summary.device_model) row.querySelector('.col-model').innerText = summary.device_model;
if (summary.device_serial) row.querySelector('.col-serial').innerText = summary.device_serial;
if (summary.firmware_version) row.querySelector('.col-firmware').innerText = summary.firmware_version;
}
document.getElementById('ssh-status').innerText = summary.ssh_success ? '✅ Success' : '❌ Failed';
document.getElementById('ssh-status').style.color = summary.ssh_success ? 'green' : 'red';
document.getElementById('original-config-status').style.display = summary.original_config ? 'block' : 'none';
document.getElementById('no-original-config-status').style.display = summary.original_config ? 'none' : 'block';
document.getElementById('original-config-content').innerText = summary.original_config || '';
document.getElementById('original-config-pane').style.display = 'none';
if (summary.parsed_current_config) {
document.getElementById('service-options').style.display = 'block';
document.getElementById('orig-marge').innerText = summary.parsed_current_config.margeServerUrl;
document.getElementById('orig-stats').innerText = summary.parsed_current_config.statsServerUrl;
document.getElementById('orig-sw_update').innerText = summary.parsed_current_config.swUpdateUrl;
document.getElementById('orig-bmx').innerText = summary.parsed_current_config.bmxRegistryUrl;
} else {
document.getElementById('service-options').style.display = 'none';
}
const remoteStatus = document.getElementById('remote-services-status');
const remoteFound = document.getElementById('remote-services-found');
if (summary.ssh_success) {
if (summary.remote_services_enabled) {
remoteStatus.innerText = summary.remote_services_persistent ? '✅ Yes' : '⚠️ Yes (non-persistent)';
remoteStatus.style.color = summary.remote_services_persistent ? 'green' : 'orange';
} else {
remoteStatus.innerText = '❌ No';
remoteStatus.style.color = 'red';
}
remoteFound.innerText = summary.remote_services_found && summary.remote_services_found.length > 0
? '(' + summary.remote_services_found.join(', ') + ')'
: '';
} else {
remoteStatus.innerText = '❓ Unknown';
remoteStatus.style.color = 'gray';
remoteFound.innerText = '';
}
const currentConfigElem = document.getElementById('current-config');
currentConfigElem.innerText = summary.current_config;
currentConfigElem.style.color = summary.ssh_success ? 'black' : 'red';
document.getElementById('planned-config').innerText = summary.planned_config;
const migrateBtn = document.getElementById('confirm-migrate-btn');
migrateBtn.onclick = () => migrate(ip);
migrateBtn.disabled = !summary.ssh_success;
const remoteBtn = document.getElementById('ensure-remote-btn');
remoteBtn.onclick = () => ensureRemoteServices(ip);
remoteBtn.disabled = !summary.ssh_success;
const removeRemoteBtn = document.getElementById('remove-remote-btn');
removeRemoteBtn.onclick = () => removeRemoteServices(ip);
removeRemoteBtn.disabled = !summary.ssh_success || !summary.remote_services_enabled;
const backupBtn = document.getElementById('backup-config-btn');
backupBtn.onclick = () => backupConfig(ip);
backupBtn.disabled = !summary.ssh_success || !!summary.original_config;
document.getElementById('migration-summary').style.display = 'block';
document.getElementById('migration-summary').scrollIntoView();
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error fetching summary for ' + ip + ': ' + error;
}
}
function refreshSummary() {
const ip = document.getElementById('summary-ip').innerText;
if (ip) {
showSummary(ip);
}
}
async function migrate(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
return;
}
const targetUrl = document.getElementById('target-domain').value;
const proxyUrl = document.getElementById('proxy-domain').value;
const opts = {
marge: document.getElementById('opt-marge').value,
stats: document.getElementById('opt-stats').value,
sw_update: document.getElementById('opt-sw_update').value,
bmx: document.getElementById('opt-bmx').value
};
const summaryDiv = document.getElementById('migration-summary');
summaryDiv.style.display = 'none';
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Migrating ' + ip + '...';
let query = '?target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
for (let k in opts) {
query += '&' + k + '=' + encodeURIComponent(opts[k]);
}
try {
const response = await fetch('/setup/migrate/' + ip + query, { method: 'POST' });
const result = await response.json();
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully started migration for ' + ip + '. The speaker will reboot.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Migration failed for ' + ip + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error migrating ' + ip + ': ' + error;
}
}
async function ensureRemoteServices(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
return;
}
const summaryDiv = document.getElementById('migration-summary');
summaryDiv.style.display = 'none';
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Ensuring remote services for ' + ip + '...';
try {
const response = await fetch('/setup/ensure-remote-services/' + ip, { method: 'POST' });
const result = await response.json();
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully ensured remote services for ' + ip + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Failed to ensure remote services for ' + ip + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error ensuring remote services for ' + ip + ': ' + error;
}
}
async function removeRemoteServices(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
return;
}
if (!confirm('Are you sure you want to remove remote services from ' + ip + '?')) {
return;
}
const summaryDiv = document.getElementById('migration-summary');
summaryDiv.style.display = 'none';
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Removing remote services for ' + ip + '...';
try {
const response = await fetch('/setup/remove-remote-services/' + ip, { method: 'POST' });
const result = await response.json();
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully removed remote services from ' + ip + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Failed to remove remote services for ' + ip + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error removing remote services for ' + ip + ': ' + error;
}
}
async function backupConfig(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
return;
}
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Creating backup for ' + ip + '...';
try {
const response = await fetch('/setup/backup/' + ip, { method: 'POST' });
const result = await response.json();
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully created backup for ' + ip + '.';
showSummary(ip); // Refresh
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Backup failed for ' + ip + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error creating backup for ' + ip + ': ' + error;
}
}
function toggleOriginalConfig() {
const pane = document.getElementById('original-config-pane');
pane.style.display = pane.style.display === 'none' ? 'block' : 'none';
}
fetchDevices();
fetchSettings();
triggerDiscovery();
</script>
</body>
</html>
+6 -1
View File
@@ -16,8 +16,9 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r := chi.NewRouter()
r.Get("/", server.HandleRoot)
// Setup media directory for tests
// Setup media and web directories for tests
r.Get("/media/*", server.HandleMedia())
r.Get("/web/*", server.HandleWeb())
// Setup BMX for tests
r.Route("/bmx", func(r chi.Router) {
@@ -50,6 +51,10 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices)
r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice)
r.Post("/test-connection/{deviceIP}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection)
r.Get("/ca.crt", server.HandleGetCACert)
})
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
+11
View File
@@ -0,0 +1,11 @@
body { font-family: sans-serif; margin: 20px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
button { padding: 5px 10px; cursor: pointer; }
.status { margin-top: 10px; padding: 10px; border: 1px solid #ccc; display: none; }
.summary-box { margin-top: 20px; padding: 15px; border: 1px solid #aaa; background-color: #f9f9f9; display: none; }
pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px; }
.diff-container { display: flex; gap: 10px; }
.diff-pane { flex: 1; min-width: 0; }
.config-header { font-weight: bold; margin-bottom: 5px; display: block; }
+159
View File
@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Soundcork Management</title>
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
<link rel="stylesheet" href="/web/css/style.css">
</head>
<body>
<h1>Soundcork Management</h1>
<h2>Discovered Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
<div id="device-list">Loading devices...</div>
<div id="manual-entry" style="margin-top: 20px; border-top: 1px solid #eee; padding-top: 10px;">
<h3>Manual Entry</h3>
<input type="text" id="manual-ip" placeholder="Device IP (e.g. 192.168.1.100)">
<button onclick="showSummary(document.getElementById('manual-ip').value)">Check Migration</button>
<h3 style="margin-top: 20px;">Settings</h3>
<div style="margin-bottom: 10px;">
<label for="target-domain">Target Domain:</label>
<input type="text" id="target-domain" placeholder="http://localhost:8000" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(This URL will be used for standard services)</span>
</div>
<div style="margin-bottom: 10px;">
<label for="proxy-domain">Proxy Domain:</label>
<input type="text" id="proxy-domain" placeholder="http://localhost:8000" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(This URL will be used to proxy upstream Bose services)</span>
</div>
<div style="margin-bottom: 10px;">
Proxy Logging:
<label><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
<label style="margin-left: 15px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
</div>
</div>
<div id="status" class="status"></div>
<div id="migration-summary" class="summary-box">
<h3>Migration Summary for <span id="summary-ip"></span></h3>
<p>SSH Connection: <span id="ssh-status"></span></p>
<p id="original-config-status" style="display: none;">Backup: ✅ Found .original config at <code>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code> <button onclick="toggleOriginalConfig()">Show Original Config</button></p>
<p id="no-original-config-status" style="display: none;">Backup: ❌ Not found <button id="backup-config-btn">Backup Config Now</button></p>
<p>Remote Services Enabled: <span id="remote-services-status"></span> <span id="remote-services-found" style="font-size: 0.8em; color: #666;"></span></p>
<p>Local Root CA Trusted: <span id="ca-trust-status"></span></p>
<div id="connection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #eefbff;">
<strong>HTTPS Connection Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device can reach the server over HTTPS.</span>
<div style="margin-top: 10px;">
URL: <code id="test-url"></code>
</div>
<div style="margin-top: 10px;">
<button id="test-connection-explicit-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Explicit CA.crt</button>
<button id="test-connection-trusted-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Shared Trust Store</button>
</div>
<div id="test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div id="hosts-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #fff4e6; display: none;">
<strong>Preliminary /etc/hosts Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device's /etc/hosts mechanism before full migration.</span>
<div style="margin-top: 10px;">
Domain: <code>custom-test-api.bose.fake</code>
</div>
<div style="margin-top: 10px;">
<button id="test-hosts-btn" style="background-color: #FF9800; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test Hosts Redirection</button>
</div>
<div id="hosts-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #f9f9f9;">
<label for="migration-method"><strong>Migration Method:</strong></label>
<select id="migration-method" onchange="toggleMigrationMethod()">
<option value="xml">XML Configuration (Recommended - redirects specific services)</option>
<option value="hosts">/etc/hosts + Root CA (Advanced - global redirection)</option>
</select>
</div>
<div id="original-config-pane" style="display: none; margin-bottom: 20px;">
<span class="config-header">Original Config (Backup)</span>
<pre id="original-config-content"></pre>
</div>
<div id="service-options" style="margin-bottom: 20px; display: none;">
<h4>Service Implementations</h4>
<table>
<tr><th>Service</th><th>Original URL</th><th>Implementation</th></tr>
<tr>
<td>Marge (Streaming)</td>
<td id="orig-marge">loading...</td>
<td>
<select id="opt-marge" onchange="refreshSummary()">
<option value="soundcork">Soundcork (Go/Python)</option>
<option value="original">Original (Proxy via soundcork-go)</option>
</select>
</td>
</tr>
<tr>
<td>Stats</td>
<td id="orig-stats">loading...</td>
<td>
<select id="opt-stats" onchange="refreshSummary()">
<option value="soundcork">Soundcork (Go/Python)</option>
<option value="original">Original (Proxy via soundcork-go)</option>
</select>
</td>
</tr>
<tr>
<td>Software Update</td>
<td id="orig-sw_update">loading...</td>
<td>
<select id="opt-sw_update" onchange="refreshSummary()">
<option value="soundcork">Soundcork (Go/Python)</option>
<option value="original">Original (Proxy via soundcork-go)</option>
</select>
</td>
</tr>
<tr>
<td>BMX (Registry)</td>
<td id="orig-bmx">loading...</td>
<td>
<select id="opt-bmx" onchange="refreshSummary()">
<option value="soundcork">Soundcork (Go/Python)</option>
<option value="original">Original (Proxy via soundcork-go)</option>
</select>
</td>
</tr>
</table>
</div>
<div class="diff-container">
<div id="xml-diff-pane" class="diff-pane">
<span class="config-header">Current Config (on Speaker)</span>
<pre id="current-config"></pre>
</div>
<div id="planned-xml-pane" class="diff-pane">
<span class="config-header">Planned Config (Soundcork)</span>
<pre id="planned-config"></pre>
</div>
<div id="planned-hosts-pane" class="diff-pane" style="display: none;">
<span class="config-header">Planned /etc/hosts Entries</span>
<pre id="planned-hosts"></pre>
<div style="margin-top: 10px; font-size: 0.9em; color: #666;">
<strong>Note:</strong> This method also injects the local Root CA into <code>/etc/pki/tls/certs/ca-bundle.crt</code> to enable secure HTTPS communication.
</div>
</div>
</div>
<div style="margin-top: 15px;">
<button id="confirm-migrate-btn" style="background-color: #4CAF50; color: white; border: none; padding: 10px 20px;">Confirm Migration & Reboot</button>
<button id="ensure-remote-btn" style="background-color: #2196F3; color: white; border: none; padding: 10px 20px;">Enable Persistent Remote Services</button>
<button id="remove-remote-btn" style="background-color: #f44336; color: white; border: none; padding: 10px 20px;">Remove Persistent Remote Services</button>
<button onclick="document.getElementById('migration-summary').style.display='none'" style="padding: 10px 20px;">Cancel</button>
</div>
</div>
<script src="/web/js/script.js"></script>
</body>
</html>
+489
View File
@@ -0,0 +1,489 @@
async function fetchSettings() {
try {
const response = await fetch('/setup/settings');
const settings = await response.json();
if (settings.server_url) {
document.getElementById('target-domain').value = settings.server_url;
}
if (settings.proxy_url) {
document.getElementById('proxy-domain').value = settings.proxy_url;
}
fetchProxySettings();
} catch (error) {
console.error('Failed to fetch settings', error);
}
}
async function fetchProxySettings() {
try {
const response = await fetch('/setup/proxy-settings');
const settings = await response.json();
document.getElementById('proxy-redact').checked = settings.redact;
document.getElementById('proxy-log-body').checked = settings.log_body;
} catch (error) {
console.error('Failed to fetch proxy settings', error);
}
}
async function updateProxySettings() {
const settings = {
redact: document.getElementById('proxy-redact').checked,
log_body: document.getElementById('proxy-log-body').checked
};
try {
await fetch('/setup/proxy-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
} catch (error) {
console.error('Failed to update proxy settings', error);
}
}
async function fetchDevices() {
try {
const response = await fetch('/setup/devices');
const devices = await response.json();
const container = document.getElementById('device-list');
if (devices.length === 0) {
container.innerHTML = 'No devices found.';
} else {
let html = '<table><tr><th>Name</th><th>IP Address</th><th>Model</th><th>Serial Number</th><th>Firmware</th><th>Action</th></tr>';
devices.forEach(d => {
html += `
<tr id="device-row-${d.ip_address.replace(/\./g, '-')}">
<td class="col-name">${d.name}</td>
<td class="col-ip">${d.ip_address}</td>
<td class="col-model">${d.product_code}</td>
<td class="col-serial">${d.device_serial_number}</td>
<td class="col-firmware">${d.firmware_version || '0.0.0'}</td>
<td><button onclick="showSummary('${d.ip_address}')">Prepare Migration</button></td>
</tr>
`;
});
html += '</table>';
container.innerHTML = html;
// Asynchronously fetch live info for each device
devices.forEach(d => updateDeviceInfo(d.ip_address));
}
} catch (error) {
document.getElementById('device-list').innerHTML = 'Error loading devices: ' + error;
}
}
async function triggerDiscovery() {
const indicator = document.getElementById('discovery-indicator');
indicator.style.display = 'inline';
try {
await fetch('/setup/discover', { method: 'POST' });
pollDiscoveryStatus();
} catch (error) {
console.error('Failed to trigger discovery', error);
indicator.style.display = 'none';
}
}
async function pollDiscoveryStatus() {
const indicator = document.getElementById('discovery-indicator');
try {
const response = await fetch('/setup/discovery-status');
const data = await response.json();
if (data.discovering) {
setTimeout(pollDiscoveryStatus, 2000);
} else {
indicator.style.display = 'none';
fetchDevices();
}
} catch (error) {
console.error('Failed to check discovery status', error);
indicator.style.display = 'none';
}
}
async function updateDeviceInfo(ip) {
try {
const response = await fetch('/setup/info/' + ip);
if (!response.ok) return;
const info = await response.json();
const rowId = 'device-row-' + ip.replace(/\./g, '-');
const row = document.getElementById(rowId);
if (row) {
if (info.name) row.querySelector('.col-name').innerText = info.name;
if (info.type) row.querySelector('.col-model').innerText = info.type;
if (info.serialNumber) row.querySelector('.col-serial').innerText = info.serialNumber;
if (info.softwareVersion) row.querySelector('.col-firmware').innerText = info.softwareVersion;
}
} catch (error) {
console.warn('Failed to fetch live info for ' + ip, error);
}
}
async function showSummary(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
return;
}
const targetUrl = document.getElementById('target-domain').value;
const proxyUrl = document.getElementById('proxy-domain').value;
const opts = {
marge: document.getElementById('opt-marge').value,
stats: document.getElementById('opt-stats').value,
sw_update: document.getElementById('opt-sw_update').value,
bmx: document.getElementById('opt-bmx').value
};
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Fetching summary for ' + ip + '...';
let query = '?target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
for (let k in opts) {
query += '&' + k + '=' + encodeURIComponent(opts[k]);
}
try {
const response = await fetch('/setup/summary/' + ip + query);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText);
}
const summary = await response.json();
statusDiv.style.display = 'none';
document.getElementById('summary-ip').innerText = ip;
// Update table row if it exists
const rowId = 'device-row-' + ip.replace(/\./g, '-');
const row = document.getElementById(rowId);
if (row) {
if (summary.device_name) row.querySelector('.col-name').innerText = summary.device_name;
if (summary.device_model) row.querySelector('.col-model').innerText = summary.device_model;
if (summary.device_serial) row.querySelector('.col-serial').innerText = summary.device_serial;
if (summary.firmware_version) row.querySelector('.col-firmware').innerText = summary.firmware_version;
}
document.getElementById('ssh-status').innerText = summary.ssh_success ? '✅ Success' : '❌ Failed';
document.getElementById('ssh-status').style.color = summary.ssh_success ? 'green' : 'red';
document.getElementById('original-config-status').style.display = summary.original_config ? 'block' : 'none';
document.getElementById('no-original-config-status').style.display = summary.original_config ? 'none' : 'block';
document.getElementById('original-config-content').innerText = summary.original_config || '';
document.getElementById('original-config-pane').style.display = 'none';
if (summary.parsed_current_config) {
document.getElementById('service-options').style.display = 'block';
document.getElementById('orig-marge').innerText = summary.parsed_current_config.margeServerUrl;
document.getElementById('orig-stats').innerText = summary.parsed_current_config.statsServerUrl;
document.getElementById('orig-sw_update').innerText = summary.parsed_current_config.swUpdateUrl;
document.getElementById('orig-bmx').innerText = summary.parsed_current_config.bmxRegistryUrl;
} else {
document.getElementById('service-options').style.display = 'none';
}
const remoteStatus = document.getElementById('remote-services-status');
const remoteFound = document.getElementById('remote-services-found');
if (summary.ssh_success) {
if (summary.remote_services_enabled) {
remoteStatus.innerText = summary.remote_services_persistent ? '✅ Yes' : '⚠️ Yes (non-persistent)';
remoteStatus.style.color = summary.remote_services_persistent ? 'green' : 'orange';
} else {
remoteStatus.innerText = '❌ No';
remoteStatus.style.color = 'red';
}
remoteFound.innerText = summary.remote_services_found && summary.remote_services_found.length > 0
? '(' + summary.remote_services_found.join(', ') + ')'
: '';
const caTrustStatus = document.getElementById('ca-trust-status');
caTrustStatus.innerText = summary.ca_cert_trusted ? '✅ Yes' : '❌ No';
caTrustStatus.style.color = summary.ca_cert_trusted ? 'green' : 'red';
} else {
remoteStatus.innerText = '❓ Unknown';
remoteStatus.style.color = 'gray';
remoteFound.innerText = '';
const caTrustStatus = document.getElementById('ca-trust-status');
caTrustStatus.innerText = '❓ Unknown';
caTrustStatus.style.color = 'gray';
}
const currentConfigElem = document.getElementById('current-config');
currentConfigElem.innerText = summary.current_config;
currentConfigElem.style.color = summary.ssh_success ? 'black' : 'red';
document.getElementById('planned-config').innerText = summary.planned_config;
document.getElementById('planned-hosts').innerText = summary.planned_hosts || '';
const testUrlElem = document.getElementById('test-url');
testUrlElem.innerText = summary.server_https_url || 'N/A';
const testResultDiv = document.getElementById('test-result');
testResultDiv.style.display = 'none';
testResultDiv.innerText = '';
document.getElementById('test-connection-explicit-btn').onclick = () => testConnection(ip, true);
document.getElementById('test-connection-trusted-btn').onclick = () => testConnection(ip, false);
document.getElementById('test-hosts-btn').onclick = () => testHostsRedirection(ip);
toggleMigrationMethod();
const migrateBtn = document.getElementById('confirm-migrate-btn');
migrateBtn.onclick = () => migrate(ip);
migrateBtn.disabled = !summary.ssh_success;
const remoteBtn = document.getElementById('ensure-remote-btn');
remoteBtn.onclick = () => ensureRemoteServices(ip);
remoteBtn.disabled = !summary.ssh_success;
const removeRemoteBtn = document.getElementById('remove-remote-btn');
removeRemoteBtn.onclick = () => removeRemoteServices(ip);
removeRemoteBtn.disabled = !summary.ssh_success || !summary.remote_services_enabled;
const backupBtn = document.getElementById('backup-config-btn');
backupBtn.onclick = () => backupConfig(ip);
backupBtn.disabled = !summary.ssh_success || !!summary.original_config;
document.getElementById('migration-summary').style.display = 'block';
document.getElementById('migration-summary').scrollIntoView();
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error fetching summary for ' + ip + ': ' + error;
}
}
function refreshSummary() {
const ip = document.getElementById('summary-ip').innerText;
if (ip) {
showSummary(ip);
}
}
async function migrate(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
return;
}
const targetUrl = document.getElementById('target-domain').value;
const proxyUrl = document.getElementById('proxy-domain').value;
const method = document.getElementById('migration-method').value;
const opts = {
marge: document.getElementById('opt-marge').value,
stats: document.getElementById('opt-stats').value,
sw_update: document.getElementById('opt-sw_update').value,
bmx: document.getElementById('opt-bmx').value
};
const summaryDiv = document.getElementById('migration-summary');
summaryDiv.style.display = 'none';
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Migrating ' + ip + ' using ' + method + '...';
let query = '?method=' + encodeURIComponent(method) + '&target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
for (let k in opts) {
query += '&' + k + '=' + encodeURIComponent(opts[k]);
}
try {
const response = await fetch('/setup/migrate/' + ip + query, { method: 'POST' });
const result = await response.json();
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully started migration for ' + ip + '. The speaker will reboot.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Migration failed for ' + ip + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error migrating ' + ip + ': ' + error;
}
}
async function ensureRemoteServices(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
return;
}
const summaryDiv = document.getElementById('migration-summary');
summaryDiv.style.display = 'none';
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Ensuring remote services for ' + ip + '...';
try {
const response = await fetch('/setup/ensure-remote-services/' + ip, { method: 'POST' });
const result = await response.json();
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully ensured remote services for ' + ip + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Failed to ensure remote services for ' + ip + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error ensuring remote services for ' + ip + ': ' + error;
}
}
async function removeRemoteServices(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
return;
}
if (!confirm('Are you sure you want to remove remote services from ' + ip + '?')) {
return;
}
const summaryDiv = document.getElementById('migration-summary');
summaryDiv.style.display = 'none';
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Removing remote services for ' + ip + '...';
try {
const response = await fetch('/setup/remove-remote-services/' + ip, { method: 'POST' });
const result = await response.json();
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully removed remote services from ' + ip + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Failed to remove remote services for ' + ip + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error removing remote services for ' + ip + ': ' + error;
}
}
async function backupConfig(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
return;
}
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Creating backup for ' + ip + '...';
try {
const response = await fetch('/setup/backup/' + ip, { method: 'POST' });
const result = await response.json();
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully created backup for ' + ip + '.';
showSummary(ip); // Refresh
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Backup failed for ' + ip + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error creating backup for ' + ip + ': ' + error;
}
}
async function testConnection(ip, useExplicitCA) {
const testUrl = document.getElementById('test-url').innerText;
const testResultDiv = document.getElementById('test-result');
testResultDiv.style.display = 'block';
testResultDiv.style.backgroundColor = '#f0f0f0';
testResultDiv.style.color = 'black';
testResultDiv.innerText = 'Running connection test from ' + ip + '...\n(This may take a few seconds)';
try {
const query = `?target_url=${encodeURIComponent(testUrl)}&use_explicit_ca=${useExplicitCA}`;
const response = await fetch(`/setup/test-connection/${ip}${query}`, { method: 'POST' });
const result = await response.json();
if (result.ok) {
testResultDiv.style.backgroundColor = '#ccffcc';
testResultDiv.innerText = '✅ ' + result.message + '\n\nOutput:\n' + result.output;
} else {
testResultDiv.style.backgroundColor = '#ffcccc';
testResultDiv.innerText = '❌ Connection failed: ' + result.message + '\n\nOutput:\n' + result.output;
}
} catch (error) {
testResultDiv.style.backgroundColor = '#ffcccc';
testResultDiv.innerText = '❌ Error triggering test: ' + error;
}
}
async function testHostsRedirection(ip) {
const targetUrl = document.getElementById('target-domain').value;
const testResultDiv = document.getElementById('hosts-test-result');
testResultDiv.style.display = 'block';
testResultDiv.style.backgroundColor = '#f0f0f0';
testResultDiv.style.color = 'black';
testResultDiv.innerText = 'Running hosts redirection test from ' + ip + '...\n(This may take a few seconds)';
try {
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
const response = await fetch(`/setup/test-hosts/${ip}${query}`, { method: 'POST' });
const result = await response.json();
if (result.ok) {
testResultDiv.style.backgroundColor = '#ccffcc';
testResultDiv.innerText = '✅ ' + result.message + '\n\nOutput:\n' + result.output;
} else {
testResultDiv.style.backgroundColor = '#ffcccc';
testResultDiv.innerText = '❌ Test failed: ' + result.message + '\n\nOutput:\n' + result.output;
}
} catch (error) {
testResultDiv.style.backgroundColor = '#ffcccc';
testResultDiv.innerText = '❌ Error triggering test: ' + error;
}
}
function toggleOriginalConfig() {
const pane = document.getElementById('original-config-pane');
pane.style.display = pane.style.display === 'none' ? 'block' : 'none';
}
function toggleMigrationMethod() {
const method = document.getElementById('migration-method').value;
const xmlDiffPane = document.getElementById('xml-diff-pane');
const plannedXmlPane = document.getElementById('planned-xml-pane');
const plannedHostsPane = document.getElementById('planned-hosts-pane');
const serviceOptions = document.getElementById('service-options');
const hostsTestPane = document.getElementById('hosts-redirection-test');
if (method === 'hosts') {
xmlDiffPane.style.display = 'none';
plannedXmlPane.style.display = 'none';
plannedHostsPane.style.display = 'block';
serviceOptions.style.display = 'none';
hostsTestPane.style.display = 'block';
} else {
xmlDiffPane.style.display = 'block';
plannedXmlPane.style.display = 'block';
plannedHostsPane.style.display = 'none';
hostsTestPane.style.display = 'none';
// Only show service options if we have a parsed config
const currentConfig = document.getElementById('current-config').innerText;
if (currentConfig && !currentConfig.startsWith('Error') && currentConfig !== 'loading...') {
serviceOptions.style.display = 'block';
}
}
}
document.addEventListener('DOMContentLoaded', () => {
fetchDevices();
fetchSettings();
triggerDiscovery();
});
+441 -9
View File
@@ -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), &currentCfg) == 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()
}
+444 -3
View File
@@ -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 {