diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go
index d269376..8079630 100644
--- a/cmd/soundtouch-service/main.go
+++ b/cmd/soundtouch-service/main.go
@@ -428,6 +428,8 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
r.Get("/info/{deviceIP}", server.HandleGetDeviceInfo)
r.Get("/summary/{deviceIP}", server.HandleGetMigrationSummary)
r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice)
+ r.Post("/revert/{deviceIP}", server.HandleRevertMigration)
+ r.Post("/reboot/{deviceIP}", server.HandleRebootDevice)
r.Post("/trust-ca/{deviceIP}", server.HandleTrustCACert)
r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices)
diff --git a/doc.go b/doc.go
index 7147731..5346f64 100644
--- a/doc.go
+++ b/doc.go
@@ -1,8 +1,11 @@
-// Package soundtouch provides a comprehensive Go library and CLI tool for controlling Bose SoundTouch devices.
+// Package soundtouch provides a comprehensive Go library, CLI tool, and local service for controlling and emulating Bose SoundTouch devices.
//
-// This library implements the complete Bose SoundTouch Web API, enabling programmatic control
+// This project implements the complete Bose SoundTouch Web API, enabling programmatic control
// of SoundTouch speakers including playback control, volume management, source selection,
-// multiroom zone management, and real-time event monitoring via WebSocket connections.
+// multiroom zone management, and real-time event monitoring.
+//
+// It also provides a local service (`soundtouch-service`) that can emulate the Bose Cloud,
+// allowing for offline control and enhanced debugging through HTTP interaction recording.
//
// # Quick Start
//
@@ -41,63 +44,20 @@
// if err != nil {
// log.Fatal(err)
// }
-//
-// // Set volume
-// err = client.SetVolume(50)
-// if err != nil {
-// log.Fatal(err)
-// }
// }
//
-// # Device Discovery
+// # SoundTouch Service
//
-// Automatically discover SoundTouch devices on your network:
+// The `soundtouch-service` provides several advanced features:
//
-// import "github.com/gesellix/bose-soundtouch/pkg/discovery"
+// - Bose Cloud Emulation: Allows speakers to work without an internet connection.
+// - HTTP Interaction Recording: Captures all traffic as IntelliJ-compatible .http files.
+// - Speaker Migration: Automated tools to redirect speakers to the local service.
+// - Web Interface: A management dashboard for proxy settings and speaker setup.
//
-// // Discover devices using UPnP/SSDP
-// service := discovery.NewService(5*time.Second)
-// devices, err := service.DiscoverDevices(ctx)
-// if err != nil {
-// log.Fatal(err)
-// }
+// Install the service:
//
-// for _, device := range devices {
-// fmt.Printf("Found device: %s at %s\n", device.Name, device.Host)
-// }
-//
-// # Real-time Events
-//
-// Monitor device state changes in real-time using WebSocket connections:
-//
-// // Subscribe to device events
-// events, err := client.SubscribeToEvents(ctx)
-// if err != nil {
-// log.Fatal(err)
-// }
-//
-// for event := range events {
-// switch e := event.(type) {
-// case *models.NowPlayingUpdated:
-// fmt.Printf("Now playing: %s by %s\n", e.Track, e.Artist)
-// case *models.VolumeUpdated:
-// fmt.Printf("Volume changed to: %d\n", e.ActualVolume)
-// }
-// }
-//
-// # Multiroom Zone Management
-//
-// Create and manage multiroom zones:
-//
-// // Create a zone with multiple speakers
-// zone := &models.Zone{
-// Master: "192.168.1.100",
-// Members: []models.ZoneMember{
-// {IPAddress: "192.168.1.101"},
-// {IPAddress: "192.168.1.102"},
-// },
-// }
-// err = client.SetZone(zone)
+// go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
//
// # CLI Tool
//
@@ -111,45 +71,33 @@
//
// # Control a device
// soundtouch-cli --host 192.168.1.100 play start
-// soundtouch-cli --host 192.168.1.100 volume set --level 50
-// soundtouch-cli --host 192.168.1.100 source select --source SPOTIFY
//
// # Supported Features
//
// - ✅ Device Information & Capabilities
-// - ✅ Playback Control (Play/Pause/Stop/Next/Previous)
-// - ✅ Volume, Bass, and Balance Control
-// - ✅ Source Selection (Spotify, Bluetooth, AUX, etc.)
-// - ✅ Preset Management
-// - ✅ Clock/Time Management
-// - ✅ Network Information
+// - ✅ Playback, Volume, Bass, and Balance Control
+// - ✅ Source Selection & Preset Management
// - ✅ Real-time WebSocket Events
// - ✅ Multiroom Zone Management
// - ✅ Device Discovery (UPnP/SSDP and mDNS)
-// - ✅ Cross-platform Support (Windows, macOS, Linux)
+// - ✅ Local Cloud Emulation (soundtouch-service)
+// - ✅ HTTP Traffic Recording & Sanitization
+// - ✅ Automated Speaker Migration & Revert
//
// # Package Structure
//
// - client: HTTP client for SoundTouch Web API
// - discovery: Device discovery using UPnP/SSDP and mDNS
// - models: Data structures for API requests/responses
-// - config: Configuration management
+// - service: Core logic for the soundtouch-service (proxy, recording, setup)
// - cmd/soundtouch-cli: Command-line interface tool
-//
-// # Hardware Compatibility
-//
-// This library has been tested with real Bose SoundTouch hardware and supports
-// all SoundTouch-compatible devices including:
-// - SoundTouch 10, 20, 30 series
-// - SoundTouch Portable
-// - Wave SoundTouch music system
-// - And other SoundTouch-enabled Bose speakers
+// - cmd/soundtouch-service: Local cloud emulation service
//
// # Implementation Notes
//
-// This implementation is based on the official Bose SoundTouch Web API documentation
-// and provides 90% coverage of all available endpoints. It is an independent project
-// and is not affiliated with or endorsed by Bose Corporation.
+// This project is an independent effort to preserve the functionality of Bose SoundTouch
+// devices and provide enhanced debugging and control capabilities. It is not
+// affiliated with or endorsed by Bose Corporation.
//
// For detailed API documentation, examples, and advanced usage patterns, visit:
// https://pkg.go.dev/github.com/gesellix/bose-soundtouch
diff --git a/pkg/service/handlers/handlers_media_test.go b/pkg/service/handlers/handlers_media_test.go
index 77b4429..b06f54f 100644
--- a/pkg/service/handlers/handlers_media_test.go
+++ b/pkg/service/handlers/handlers_media_test.go
@@ -35,8 +35,8 @@ func TestRootEndpoint(t *testing.T) {
}
body, _ := io.ReadAll(res.Body)
- if !strings.Contains(string(body), "Soundcork Management") {
- t.Errorf("Expected body to contain 'Soundcork Management', got %s", string(body))
+ if !strings.Contains(string(body), "Bose SoundTouch Toolkit") {
+ t.Errorf("Expected body to contain 'Bose SoundTouch Toolkit', got %s", string(body))
}
}
diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go
index 0aa528f..639f839 100644
--- a/pkg/service/handlers/handlers_setup.go
+++ b/pkg/service/handlers/handlers_setup.go
@@ -184,11 +184,12 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
}
}
- if err := s.sm.MigrateSpeaker(deviceIP, targetURL, proxyURL, options, method); err != nil {
+ output, err := s.sm.MigrateSpeaker(deviceIP, targetURL, proxyURL, options, method)
+ if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
- if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
+ if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -198,7 +199,43 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
- if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Migration started"}); err != nil {
+ if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Migration started", "output": output}); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ return
+ }
+}
+
+// HandleRevertMigration reverts the migration for a device.
+func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
+ deviceIP := chi.URLParam(r, "deviceIP")
+ if deviceIP == "" {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ return
+ }
+
+ return
+ }
+
+ output, err := s.sm.RevertMigration(deviceIP)
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+
+ if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ return
+ }
+
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+
+ if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Revert started", "output": output}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -219,11 +256,12 @@ func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) {
return
}
- if err := s.sm.TrustCACert(deviceIP); err != nil {
+ output, err := s.sm.TrustCACert(deviceIP)
+ if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
- if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
+ if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -233,7 +271,7 @@ func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
- if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Root CA trusted"}); err != nil {
+ if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Root CA trusted", "output": output}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -254,11 +292,12 @@ func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Reque
return
}
- if err := s.sm.EnsureRemoteServices(deviceIP); err != nil {
+ output, err := s.sm.EnsureRemoteServices(deviceIP)
+ if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
- if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
+ if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -268,7 +307,7 @@ func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Reque
w.Header().Set("Content-Type", "application/json")
- if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Remote services ensured"}); err != nil {
+ if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Remote services enabled", "output": output}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -289,11 +328,12 @@ func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Reque
return
}
- if err := s.sm.RemoveRemoteServices(deviceIP); err != nil {
+ output, err := s.sm.RemoveRemoteServices(deviceIP)
+ if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
- if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
+ if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -303,7 +343,7 @@ func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Reque
w.Header().Set("Content-Type", "application/json")
- if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Remote services removed"}); err != nil {
+ if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Remote services removed", "output": output}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -324,11 +364,12 @@ func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) {
return
}
- if err := s.sm.BackupConfig(deviceIP); err != nil {
+ output, err := s.sm.BackupConfig(deviceIP)
+ if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
- if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
+ if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -338,7 +379,7 @@ func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
- if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Backup created"}); err != nil {
+ if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Config backed up", "output": output}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -454,6 +495,42 @@ func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok": true}`))
}
+// HandleRebootDevice reboots a device.
+func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) {
+ deviceIP := chi.URLParam(r, "deviceIP")
+ if deviceIP == "" {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ return
+ }
+
+ return
+ }
+
+ output, err := s.sm.Reboot(deviceIP)
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+
+ if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ return
+ }
+
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+
+ if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Reboot started", "output": output}); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ return
+ }
+}
+
// 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")
diff --git a/pkg/service/handlers/handlers_setup_test.go b/pkg/service/handlers/handlers_setup_test.go
index f91fc8c..b0e5fdd 100644
--- a/pkg/service/handlers/handlers_setup_test.go
+++ b/pkg/service/handlers/handlers_setup_test.go
@@ -144,6 +144,9 @@ func TestMigrationAndCA(t *testing.T) {
if result["ok"] != true {
t.Errorf("Migrate: Expected ok=true, got %v", result["ok"])
}
+ if _, ok := result["output"]; !ok {
+ t.Errorf("Migrate: Expected output field in response")
+ }
// 3. Test POST /setup/trust-ca/{deviceIP}
res, err = http.Post(ts.URL+"/setup/trust-ca/192.168.1.10", "application/json", nil)
@@ -162,6 +165,51 @@ func TestMigrationAndCA(t *testing.T) {
if result["ok"] != true {
t.Errorf("TrustCA: Expected ok=true, got %v", result["ok"])
}
+ if _, ok := result["output"]; !ok {
+ t.Errorf("TrustCA: Expected output field in response")
+ }
+
+ // 4. Test POST /setup/reboot/{deviceIP}
+ res, err = http.Post(ts.URL+"/setup/reboot/192.168.1.10", "application/json", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ t.Errorf("Reboot: Expected status OK, got %v", res.Status)
+ }
+
+ if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
+ t.Fatalf("Reboot: Failed to decode response: %v", err)
+ }
+ if result["ok"] != true {
+ t.Errorf("Reboot: Expected ok=true, got %v", result["ok"])
+ }
+ if _, ok := result["output"]; !ok {
+ t.Errorf("Reboot: Expected output field in response")
+ }
+
+ // 5. Test POST /setup/remove-remote-services/{deviceIP}
+ res, err = http.Post(ts.URL+"/setup/remove-remote-services/192.168.1.10", "application/json", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ t.Errorf("RemoveRemote: Expected status OK, got %v", res.Status)
+ }
+
+ if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
+ t.Fatalf("RemoveRemote: Failed to decode response: %v", err)
+ }
+ if result["ok"] != true {
+ t.Errorf("RemoveRemote: Expected ok=true, got %v", result["ok"])
+ }
+ if _, ok := result["output"]; !ok {
+ t.Errorf("RemoveRemote: Expected output field in response")
+ }
}
type mockSSH struct{}
diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go
index 9dda9aa..792bdbd 100644
--- a/pkg/service/handlers/main_test.go
+++ b/pkg/service/handlers/main_test.go
@@ -52,6 +52,8 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices)
r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice)
+ r.Post("/revert/{deviceIP}", server.HandleRevertMigration)
+ r.Post("/reboot/{deviceIP}", server.HandleRebootDevice)
r.Post("/trust-ca/{deviceIP}", server.HandleTrustCACert)
r.Post("/test-connection/{deviceIP}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection)
diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html
index 46531dc..2f77a4b 100644
--- a/pkg/service/handlers/web/index.html
+++ b/pkg/service/handlers/web/index.html
@@ -2,12 +2,12 @@
- Soundcork Management
+ Bose SoundTouch Toolkit
- Soundcork Management
+ Bose SoundTouch Toolkit
-
+
+
+
diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js
index 059d612..20be63f 100644
--- a/pkg/service/handlers/web/js/script.js
+++ b/pkg/service/handlers/web/js/script.js
@@ -293,6 +293,9 @@ async function showSummary(ip) {
query += '&' + k + '=' + encodeURIComponent(opts[k]);
}
+ const outputBox = document.getElementById('command-output-box');
+ if (outputBox) outputBox.style.display = 'none';
+
try {
const response = await fetch('/setup/summary/' + ip + query);
if (!response.ok) {
@@ -391,6 +394,15 @@ async function showSummary(ip) {
migrateBtn.onclick = () => migrate(ip);
migrateBtn.disabled = !summary.ssh_success;
+ const revertBtn = document.getElementById('revert-migrate-btn');
+ revertBtn.onclick = () => revert(ip);
+ revertBtn.disabled = !summary.ssh_success;
+ revertBtn.style.display = summary.original_config ? 'inline-block' : 'none';
+
+ const rebootBtn = document.getElementById('reboot-speaker-btn');
+ rebootBtn.onclick = () => reboot(ip);
+ rebootBtn.disabled = !summary.ssh_success;
+
const remoteBtn = document.getElementById('ensure-remote-btn');
remoteBtn.onclick = () => ensureRemoteServices(ip);
remoteBtn.disabled = !summary.ssh_success;
@@ -418,6 +430,82 @@ function refreshSummary() {
}
}
+function showCommandOutput(result) {
+ const outputBox = document.getElementById('command-output-box');
+ const outputText = document.getElementById('command-output');
+ if (outputBox && outputText && result.output) {
+ outputBox.style.display = 'block';
+ outputText.innerText = result.output;
+ } else if (outputBox) {
+ outputBox.style.display = 'none';
+ }
+}
+
+async function revert(ip) {
+ if (!ip) {
+ alert('Please enter a valid IP address.');
+ return;
+ }
+ if (!confirm('Are you sure you want to revert ' + ip + ' to Bose cloud defaults?')) {
+ 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 = 'Reverting ' + ip + ' to defaults...';
+
+ try {
+ const response = await fetch('/setup/revert/' + ip, { method: 'POST' });
+ const result = await response.json();
+ showCommandOutput(result);
+ if (result.ok) {
+ statusDiv.style.backgroundColor = '#ccffcc';
+ statusDiv.innerHTML = 'Successfully started revert for ' + ip + '.';
+ } else {
+ statusDiv.style.backgroundColor = '#ffcccc';
+ statusDiv.innerHTML = 'Revert failed for ' + ip + ': ' + (result.message || 'Unknown error');
+ }
+ } catch (error) {
+ statusDiv.style.backgroundColor = '#ffcccc';
+ statusDiv.innerHTML = 'Error reverting ' + ip + ': ' + error;
+ }
+}
+
+async function reboot(ip) {
+ if (!ip) {
+ alert('Please enter a valid IP address.');
+ return;
+ }
+ if (!confirm('Are you sure you want to reboot the speaker at ' + ip + '?')) {
+ return;
+ }
+
+ const statusDiv = document.getElementById('status');
+ statusDiv.style.display = 'block';
+ statusDiv.style.backgroundColor = '#ffffcc';
+ statusDiv.innerHTML = 'Rebooting ' + ip + '...';
+
+ try {
+ const response = await fetch('/setup/reboot/' + ip, { method: 'POST' });
+ const result = await response.json();
+ showCommandOutput(result);
+ if (result.ok) {
+ statusDiv.style.backgroundColor = '#ccffcc';
+ statusDiv.innerHTML = 'Successfully started reboot for ' + ip + '.';
+ } else {
+ statusDiv.style.backgroundColor = '#ffcccc';
+ statusDiv.innerHTML = 'Reboot failed for ' + ip + ': ' + (result.message || 'Unknown error');
+ }
+ } catch (error) {
+ statusDiv.style.backgroundColor = '#ffcccc';
+ statusDiv.innerHTML = 'Error rebooting ' + ip + ': ' + error;
+ }
+}
+
async function migrate(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
@@ -450,9 +538,10 @@ async function migrate(ip) {
try {
const response = await fetch('/setup/migrate/' + ip + query, { method: 'POST' });
const result = await response.json();
+ showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
- statusDiv.innerHTML = 'Successfully started migration for ' + ip + '. The speaker will reboot.';
+ statusDiv.innerHTML = 'Successfully started migration for ' + ip + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Migration failed for ' + ip + ': ' + (result.message || 'Unknown error');
@@ -476,6 +565,7 @@ async function trustCA(ip) {
try {
const response = await fetch('/setup/trust-ca/' + ip, { method: 'POST' });
const result = await response.json();
+ showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully injected Root CA on ' + ip + '.';
@@ -506,6 +596,7 @@ async function ensureRemoteServices(ip) {
try {
const response = await fetch('/setup/ensure-remote-services/' + ip, { method: 'POST' });
const result = await response.json();
+ showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully ensured remote services for ' + ip + '.';
@@ -538,6 +629,7 @@ async function removeRemoteServices(ip) {
try {
const response = await fetch('/setup/remove-remote-services/' + ip, { method: 'POST' });
const result = await response.json();
+ showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully removed remote services from ' + ip + '.';
@@ -564,6 +656,7 @@ async function backupConfig(ip) {
try {
const response = await fetch('/setup/backup/' + ip, { method: 'POST' });
const result = await response.json();
+ showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully created backup for ' + ip + '.';
diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go
index 37927fb..10401f8 100644
--- a/pkg/service/setup/setup.go
+++ b/pkg/service/setup/setup.go
@@ -424,7 +424,7 @@ func (m *Manager) checkCACertTrusted(summary *MigrationSummary, deviceIP string)
}
// 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, method MigrationMethod) error {
+func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options map[string]string, method MigrationMethod) (string, error) {
if targetURL == "" {
targetURL = m.ServerURL
}
@@ -437,7 +437,11 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
return m.migrateViaHosts(deviceIP, targetURL)
}
- if err := m.EnsureRemoteServices(deviceIP); err != nil {
+ var logs string
+
+ out, err := m.EnsureRemoteServices(deviceIP)
+ logs += "Ensuring remote services:\n" + out + "\n"
+ if err != nil {
// Log but continue migration? Or fail? The requirement is "to ensure stable 'remote_services'"
// Let's log it.
fmt.Printf("Warning: failed to ensure remote services: %v\n", err)
@@ -456,6 +460,7 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
// If we have a proxyURL and can read current config, use it
client := m.NewSSH(deviceIP)
if currentConfig, err := client.Run(fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath)); err == nil && currentConfig != "" {
+ logs += "Read current configuration\n"
var currentCfg PrivateCfg
if xml.Unmarshal([]byte(currentConfig), ¤tCfg) == nil {
if proxyURL == "" {
@@ -475,7 +480,7 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
xmlContent, err := xml.MarshalIndent(cfg, "", " ")
if err != nil {
- return fmt.Errorf("failed to marshal XML: %w", err)
+ return logs, fmt.Errorf("failed to marshal XML: %w", err)
}
// Add XML header
@@ -485,74 +490,85 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
remotePath := SoundTouchSdkPrivateCfgPath
rwCmd := "(rw || mount -o remount,rw /)"
- if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err != nil {
+ if out, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err != nil {
+ logs += fmt.Sprintf("Backing up original config to %s.original (check: %s)\n", remotePath, out)
fmt.Printf("Backing up original config to %s.original\n", remotePath)
// Try to copy existing config to .original, ensuring filesystem is writable
if output, err := client.Run(fmt.Sprintf("%s && cp %s %s.original", rwCmd, remotePath, remotePath)); err != nil {
+ logs += fmt.Sprintf("Warning: failed to cp backup config: %v (output: %s)\n", err, output)
fmt.Printf("Warning: failed to cp backup config: %v (output: %s)\n", err, output)
// Fallback to manual upload if cp failed (might not have cp?)
if config, err := client.Run(fmt.Sprintf("cat %s", remotePath)); err == nil && config != "" {
if err := client.UploadContent([]byte(config), remotePath+".original"); err != nil {
+ logs += "Warning: failed to upload backup config: " + err.Error() + "\n"
fmt.Printf("Warning: failed to upload backup config: %v\n", err)
+ } else {
+ logs += "Uploaded backup config via fallback\n"
}
}
+ } else {
+ logs += "Copied backup config to .original\n"
}
+ } else {
+ logs += "Backup .original already exists\n"
}
// 1. Upload the configuration (rw is handled by calling it before if needed, but UploadContent uses cat > which needs rw)
// We'll wrap the upload in a way that EnsureRemoteServices and others might benefit,
// but UploadContent is a separate method. We should probably add rw to UploadContent or call it before.
// Actually, let's call rw before UploadContent here.
- _, _ = client.Run(rwCmd)
+ out, _ = client.Run(rwCmd)
+ logs += rwCmd + ": " + out + "\n"
if err := client.UploadContent(xmlContent, remotePath); err != nil {
- return fmt.Errorf("failed to upload config: %w", err)
+ return logs, fmt.Errorf("failed to upload config: %w", err)
}
+ logs += "Uploaded new configuration to " + remotePath + "\n"
- // 2. Reboot the speaker (requires 'rw' command first to make filesystem writable)
- if _, err := client.Run(fmt.Sprintf("%s && reboot", rwCmd)); err != nil {
- return fmt.Errorf("failed to reboot speaker: %w", err)
- }
-
- return nil
+ return logs, nil
}
// BackupConfig creates a backup of the current configuration on the speaker.
-func (m *Manager) BackupConfig(deviceIP string) error {
+func (m *Manager) BackupConfig(deviceIP string) (string, error) {
client := m.NewSSH(deviceIP)
remotePath := SoundTouchSdkPrivateCfgPath
rwCmd := "(rw || mount -o remount,rw /)"
// Check if .original already exists
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err == nil {
- return fmt.Errorf("backup already exists at %s.original", remotePath)
+ return "", fmt.Errorf("backup already exists at %s.original", remotePath)
}
// Try to copy on the device first (more reliable), ensuring filesystem is writable
output, cpErr := client.Run(fmt.Sprintf("%s && cp %s %s.original", rwCmd, remotePath, remotePath))
if cpErr == nil {
- return nil
+ return output, nil
}
+ logs := output + "\n"
fmt.Printf("Direct cp failed: %v (output: %s), falling back to cat+upload\n", cpErr, output)
// Fallback to cat + upload
config, err := client.Run(fmt.Sprintf("cat %s", remotePath))
+ logs += "cat " + remotePath + ": " + config + "\n"
if err != nil || config == "" {
- return fmt.Errorf("failed to read current config: %w", err)
+ return logs, fmt.Errorf("failed to read current config: %w", err)
}
// Ensure rw before upload fallback
- _, _ = client.Run(rwCmd)
+ out, _ := client.Run(rwCmd)
+ logs += rwCmd + ": " + out + "\n"
if err := client.UploadContent([]byte(config), remotePath+".original"); err != nil {
- return fmt.Errorf("failed to upload backup config: %w", err)
+ return logs, fmt.Errorf("failed to upload backup config: %w", err)
}
- return nil
+ logs += "Uploaded backup to " + remotePath + ".original\n"
+
+ return logs, nil
}
// EnsureRemoteServices ensures that remote services are enabled on the device.
// It tries to create an empty file in one of the known valid locations.
-func (m *Manager) EnsureRemoteServices(deviceIP string) error {
+func (m *Manager) EnsureRemoteServices(deviceIP string) (string, error) {
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
@@ -563,43 +579,55 @@ func (m *Manager) EnsureRemoteServices(deviceIP string) error {
"/tmp/remote_services",
}
+ var logs string
for _, loc := range locations {
// Try to make filesystem writable for each location that might need it
// Combining rw && touch ensures it's attempted in the same sequence
- _, err := client.Run(fmt.Sprintf("%s && touch %s", rwCmd, loc))
+ out, err := client.Run(fmt.Sprintf("%s && touch %s", rwCmd, loc))
+ logs += fmt.Sprintf("touch %s (with rw): %s\n", loc, out)
if err == nil {
- return nil
+ return logs, nil
}
// If rw && touch failed, try just touch (e.g. for /tmp which doesn't need rw)
- _, err = client.Run(fmt.Sprintf("touch %s", loc))
+ out, err = client.Run(fmt.Sprintf("touch %s", loc))
+ logs += fmt.Sprintf("touch %s: %s\n", loc, out)
if err == nil {
- return nil
+ return logs, nil
}
}
- return fmt.Errorf("failed to enable remote services in any of the locations: %v", locations)
+ return logs, fmt.Errorf("failed to enable remote services in any of the locations: %v", locations)
}
// TrustCACert injects the local CA certificate into the device's shared trust store.
-func (m *Manager) TrustCACert(deviceIP string) error {
+func (m *Manager) TrustCACert(deviceIP string) (string, error) {
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
+ var logs string
+
caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
if err != nil {
- return fmt.Errorf("failed to read CA certificate: %w", err)
+ return "", fmt.Errorf("failed to read CA certificate: %w", err)
}
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
- _, _ = client.Run(rwCmd)
+ out, _ := client.Run(rwCmd)
+ logs += rwCmd + ": " + out + "\n"
// 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))
+ out, _ := client.Run(fmt.Sprintf("cp %s %s.original", bundlePath, bundlePath))
+ logs += fmt.Sprintf("cp %s %s.original: %s\n", bundlePath, bundlePath, out)
}
// Check if the label already exists in the bundle
- bundleContent, _ := client.Run(fmt.Sprintf("cat %s", bundlePath))
+ bundleContent, err := client.Run(fmt.Sprintf("cat %s", bundlePath))
+ logs += "cat " + bundlePath + " (check existing)\n"
+ if err != nil {
+ return logs, fmt.Errorf("failed to read bundle: %w", err)
+ }
+
if strings.Contains(bundleContent, CALabel) {
// Label found, let's replace the whole block between labels if we used them,
// or just remove the lines containing the label and re-append.
@@ -637,29 +665,34 @@ func (m *Manager) TrustCACert(deviceIP string) error {
newBundleContent := bundleContent + labeledCert
if err := client.UploadContent([]byte(newBundleContent), bundlePath); err != nil {
- return fmt.Errorf("failed to update bundle: %w", err)
+ return logs, fmt.Errorf("failed to update bundle: %w", err)
}
- return nil
+ logs += "Uploaded updated bundle to " + bundlePath + "\n"
+
+ return logs, nil
}
-func (m *Manager) migrateViaHosts(deviceIP, targetURL string) error {
+func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
+ var logs string
+
// 1. Parse targetURL to get IP for /etc/hosts
parsedURL, err := url.Parse(targetURL)
if err != nil {
- return fmt.Errorf("failed to parse target URL: %w", err)
+ 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)
+ return "", fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
}
hostIP := m.resolveIP(hostName, client)
+ logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP)
// 2. Prepare /etc/hosts entries
domains := []string{
@@ -671,8 +704,9 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) error {
}
hostsContent, err := client.Run("cat /etc/hosts")
+ logs += "cat /etc/hosts: " + hostsContent + "\n"
if err != nil {
- return fmt.Errorf("failed to read /etc/hosts: %w", err)
+ return logs, fmt.Errorf("failed to read /etc/hosts: %w", err)
}
for _, domain := range domains {
@@ -688,16 +722,19 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) error {
}
// 3. Upload new /etc/hosts
- _, _ = client.Run(rwCmd)
+ out, _ := client.Run(rwCmd)
+ logs += rwCmd + ": " + out + "\n"
// Backup /etc/hosts if it doesn't exist
if _, err := client.Run("[ -f /etc/hosts.original ]"); err != nil {
- _, _ = client.Run("cp /etc/hosts /etc/hosts.original")
+ out, _ := client.Run("cp /etc/hosts /etc/hosts.original")
+ logs += "cp /etc/hosts /etc/hosts.original: " + out + "\n"
}
if err := client.UploadContent([]byte(hostsContent), "/etc/hosts"); err != nil {
- return fmt.Errorf("failed to update /etc/hosts: %w", err)
+ return logs, fmt.Errorf("failed to update /etc/hosts: %w", err)
}
+ logs += "Uploaded updated /etc/hosts\n"
fmt.Printf("Updated /etc/hosts on %s:\n%s\n", deviceIP, hostsContent)
// 4. Inject CA Certificate
@@ -705,23 +742,91 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) error {
m.checkCACertTrusted(summary, deviceIP)
if !summary.CACertTrusted {
- if err := m.TrustCACert(deviceIP); err != nil {
- return err
+ out, err := m.TrustCACert(deviceIP)
+ logs += "Trusting CA:\n" + out + "\n"
+ if err != nil {
+ return logs, err
}
} else {
+ logs += "CA certificate already trusted, skipping injection\n"
fmt.Printf("CA certificate already trusted on %s, skipping injection\n", deviceIP)
}
- // 5. Reboot
- if _, err := client.Run(fmt.Sprintf("%s && reboot", rwCmd)); err != nil {
- return fmt.Errorf("failed to reboot speaker: %w", err)
+ return logs, nil
+}
+
+// RevertMigration reverts the speaker to its original Bose cloud configuration.
+func (m *Manager) RevertMigration(deviceIP string) (string, error) {
+ client := m.NewSSH(deviceIP)
+ rwCmd := "(rw || mount -o remount,rw /)"
+
+ var logs string
+
+ // 1. Revert SoundTouchSdkPrivateCfg.xml
+ remotePath := SoundTouchSdkPrivateCfgPath
+ if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err == nil {
+ logs += fmt.Sprintf("Reverting %s from backup\n", remotePath)
+ fmt.Printf("Reverting %s from backup\n", remotePath)
+ out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, remotePath, remotePath))
+ logs += fmt.Sprintf("cp %s.original %s: %s\n", remotePath, remotePath, out)
+ if err != nil {
+ return logs, fmt.Errorf("failed to revert %s: %w", remotePath, err)
+ }
+ } else {
+ return logs, fmt.Errorf("backup %s.original not found, cannot revert", remotePath)
}
- return nil
+ // 2. Revert /etc/hosts
+ hostsPath := "/etc/hosts"
+ if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", hostsPath)); err == nil {
+ logs += fmt.Sprintf("Reverting %s from backup\n", hostsPath)
+ fmt.Printf("Reverting %s from backup\n", hostsPath)
+ out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, hostsPath, hostsPath))
+ logs += fmt.Sprintf("cp %s.original %s: %s\n", hostsPath, hostsPath, out)
+ if err != nil {
+ // Don't return error here, try to continue with other reverts
+ fmt.Printf("Warning: failed to revert %s: %v\n", hostsPath, err)
+ }
+ }
+
+ // 3. Remove CA certificate from trust store if it exists
+ bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
+ if bundleContent, err := client.Run(fmt.Sprintf("cat %s", bundlePath)); err == nil && strings.Contains(bundleContent, CALabel) {
+ logs += fmt.Sprintf("Removing local CA certificate from %s\n", bundlePath)
+ fmt.Printf("Removing local CA certificate from %s\n", bundlePath)
+ lines := strings.Split(bundleContent, "\n")
+ var newLines []string
+ inOurCA := false
+ for _, line := range lines {
+ if strings.Contains(line, CALabel) {
+ inOurCA = !inOurCA
+ continue
+ }
+ if !inOurCA {
+ newLines = append(newLines, line)
+ }
+ }
+
+ bundleContent = strings.Join(newLines, "\n")
+ if bundleContent != "" && !strings.HasSuffix(bundleContent, "\n") {
+ bundleContent += "\n"
+ }
+
+ out, _ := client.Run(rwCmd)
+ logs += rwCmd + ": " + out + "\n"
+ if err := client.UploadContent([]byte(bundleContent), bundlePath); err != nil {
+ logs += "Warning: failed to remove CA from " + bundlePath + ": " + err.Error() + "\n"
+ fmt.Printf("Warning: failed to remove CA from %s: %v\n", bundlePath, err)
+ } else {
+ logs += "Uploaded updated bundle (CA removed)\n"
+ }
+ }
+
+ return logs, nil
}
// RemoveRemoteServices removes remote services from the device by deleting the known remote_services files.
-func (m *Manager) RemoveRemoteServices(deviceIP string) error {
+func (m *Manager) RemoveRemoteServices(deviceIP string) (string, error) {
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
@@ -731,14 +836,17 @@ func (m *Manager) RemoveRemoteServices(deviceIP string) error {
"/tmp/remote_services",
}
+ var logs string
var errors []error
for _, loc := range locations {
// Try to make filesystem writable and remove the file
- _, err := client.Run(fmt.Sprintf("%s && rm -f %s", rwCmd, loc))
+ out, err := client.Run(fmt.Sprintf("%s && rm -v %s", rwCmd, loc))
+ logs += fmt.Sprintf("Removing %s: %s\n", loc, out)
if err != nil {
// If rw && rm failed, try just rm (e.g. for /tmp)
- _, err = client.Run(fmt.Sprintf("rm -f %s", loc))
+ out, err = client.Run(fmt.Sprintf("rm -v %s", loc))
+ logs += fmt.Sprintf("Fallback removing %s: %s\n", loc, out)
if err != nil {
errors = append(errors, fmt.Errorf("failed to remove %s: %w", loc, err))
}
@@ -746,10 +854,24 @@ func (m *Manager) RemoveRemoteServices(deviceIP string) error {
}
if len(errors) == len(locations) {
- return fmt.Errorf("failed to remove remote services from any location: %v", errors)
+ return logs, fmt.Errorf("failed to remove remote services from any location: %v", errors)
}
- return nil
+ return logs, nil
+}
+
+// Reboot reboots the speaker at the given IP.
+func (m *Manager) Reboot(deviceIP string) (string, error) {
+ client := m.NewSSH(deviceIP)
+ rwCmd := "(rw || mount -o remount,rw /)"
+
+ fmt.Printf("Rebooting speaker at %s\n", deviceIP)
+ out, err := client.Run(fmt.Sprintf("%s && reboot", rwCmd))
+ if err != nil {
+ return out, fmt.Errorf("failed to reboot speaker: %w", err)
+ }
+
+ return out, nil
}
// TestDomain is the fake domain used for preliminary redirection tests.
diff --git a/pkg/service/setup/setup_test.go b/pkg/service/setup/setup_test.go
index 5a6f9db..f0e9a51 100644
--- a/pkg/service/setup/setup_test.go
+++ b/pkg/service/setup/setup_test.go
@@ -72,7 +72,7 @@ func TestMigrateViaHosts(t *testing.T) {
}
}
- err = m.migrateViaHosts("192.168.1.10", "http://192.168.1.100:8000")
+ _, err = m.migrateViaHosts("192.168.1.10", "http://192.168.1.100:8000")
if err != nil {
t.Fatalf("migrateViaHosts failed: %v", err)
}
@@ -95,7 +95,7 @@ func TestMigrateViaHosts(t *testing.T) {
t.Errorf("Expected ca-bundle.crt backup to be attempted")
}
- // Verify reboot was called
+ // Verify reboot was NOT called
foundReboot := false
for _, call := range runCalls {
if strings.Contains(call, "reboot") {
@@ -103,8 +103,8 @@ func TestMigrateViaHosts(t *testing.T) {
break
}
}
- if !foundReboot {
- t.Errorf("Expected reboot to be called")
+ if foundReboot {
+ t.Errorf("Expected reboot NOT to be called automatically")
}
}
@@ -554,7 +554,7 @@ func TestMigrateViaHosts_SkipCAIfTrusted(t *testing.T) {
}
}
- err = m.migrateViaHosts("192.168.1.10", "http://192.168.1.100:8000")
+ _, err = m.migrateViaHosts("192.168.1.10", "http://192.168.1.100:8000")
if err != nil {
t.Fatalf("migrateViaHosts failed: %v", err)
}
@@ -604,7 +604,7 @@ func TestTrustCACert(t *testing.T) {
}
}
- err = m.TrustCACert("192.168.1.10")
+ _, err = m.TrustCACert("192.168.1.10")
if err != nil {
t.Fatalf("TrustCACert failed: %v", err)
}
@@ -634,6 +634,134 @@ func TestTrustCACert(t *testing.T) {
}
}
+func TestRevertMigration(t *testing.T) {
+ m := NewManager("http://localhost:8000", nil, nil)
+
+ runCalls := []string{}
+ uploadCalls := make(map[string]string)
+ m.NewSSH = func(host string) SSHClient {
+ return &mockSSH{
+ runFunc: func(command string) (string, error) {
+ runCalls = append(runCalls, command)
+ if command == "cat /etc/pki/tls/certs/ca-bundle.crt" {
+ return "existing content\n" + CALabel + "\nCERT DATA\n" + CALabel + "\nmore content", nil
+ }
+ // Mock file existence checks for .original files
+ if strings.HasPrefix(command, "[ -f") && strings.Contains(command, ".original") {
+ return "", nil // file exists
+ }
+ return "", nil
+ },
+ uploadContentFunc: func(content []byte, remotePath string) error {
+ uploadCalls[remotePath] = string(content)
+ return nil
+ },
+ }
+ }
+
+ _, err := m.RevertMigration("192.168.1.10")
+ if err != nil {
+ t.Fatalf("RevertMigration failed: %v", err)
+ }
+
+ // Verify revert commands
+ foundXMLRevert := false
+ foundHostsRevert := false
+ foundReboot := false
+ for _, call := range runCalls {
+ if strings.Contains(call, "cp "+SoundTouchSdkPrivateCfgPath+".original "+SoundTouchSdkPrivateCfgPath) {
+ foundXMLRevert = true
+ }
+ if strings.Contains(call, "cp /etc/hosts.original /etc/hosts") {
+ foundHostsRevert = true
+ }
+ if strings.Contains(call, "reboot") {
+ foundReboot = true
+ }
+ }
+
+ if !foundXMLRevert {
+ t.Errorf("Expected XML config revert")
+ }
+ if !foundHostsRevert {
+ t.Errorf("Expected /etc/hosts revert")
+ }
+ if foundReboot {
+ t.Errorf("Expected reboot NOT to be called automatically during revert")
+ }
+
+ // Verify RemoveRemoteServices was NOT called
+ for _, call := range runCalls {
+ if strings.Contains(call, "rm -f /etc/remote_services") {
+ t.Errorf("Remote services should NOT be removed during revert")
+ }
+ }
+
+ // Verify CA removal
+ if content, ok := uploadCalls["/etc/pki/tls/certs/ca-bundle.crt"]; ok {
+ if strings.Contains(content, CALabel) {
+ t.Errorf("Expected CA label to be removed from bundle, got: %s", content)
+ }
+ if !strings.Contains(content, "existing content") || !strings.Contains(content, "more content") {
+ t.Errorf("Expected existing content to be preserved in bundle, got: %s", content)
+ }
+ } else {
+ t.Errorf("Expected updated bundle to be uploaded")
+ }
+}
+
+func TestRevertMigration_NoBackup(t *testing.T) {
+ m := NewManager("http://localhost:8000", nil, nil)
+
+ m.NewSSH = func(host string) SSHClient {
+ return &mockSSH{
+ runFunc: func(command string) (string, error) {
+ if strings.HasPrefix(command, "[ -f") {
+ return "", fmt.Errorf("file not found")
+ }
+ return "", nil
+ },
+ }
+ }
+
+ _, err := m.RevertMigration("192.168.1.10")
+ if err == nil {
+ t.Errorf("Expected error when backup is missing, got nil")
+ } else if !strings.Contains(err.Error(), "backup") {
+ t.Errorf("Expected error about missing backup, got: %v", err)
+ }
+}
+
+func TestReboot(t *testing.T) {
+ m := NewManager("http://localhost:8000", nil, nil)
+
+ runCalls := []string{}
+ m.NewSSH = func(host string) SSHClient {
+ return &mockSSH{
+ runFunc: func(command string) (string, error) {
+ runCalls = append(runCalls, command)
+ return "", nil
+ },
+ }
+ }
+
+ _, err := m.Reboot("192.168.1.10")
+ if err != nil {
+ t.Fatalf("Reboot failed: %v", err)
+ }
+
+ foundReboot := false
+ for _, call := range runCalls {
+ if strings.Contains(call, "reboot") {
+ foundReboot = true
+ break
+ }
+ }
+ if !foundReboot {
+ t.Errorf("Expected reboot command to be called")
+ }
+}
+
func contains(s, substr string) bool {
return strings.Contains(s, substr)
}