From 93cfd9dbbcdfa5ef1daf5f566e37285df386baf2 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 14 Feb 2026 13:47:16 +0100 Subject: [PATCH] Implement safe migration revert with backup validation - Added strict guardrails for RevertMigration: now fails if .original backup is missing. - Revert now uses copy (cp) instead of move (mv) to preserve original backups on the device. - Decoupled reboot from migration/revert processes, making it a manual operation. - Added standalone Reboot API and manual 'Reboot Speaker' button in the Web UI. - Separated 'Remove Remote Services' from the revert process to allow independent management. - Implemented command output capture and display in the Web UI for all setup actions (Migrate, Revert, Trust CA, Backup, Reboot, Remove Remote Services). - Updated doc.go with a modern overview of the library and SoundTouch service features. - Fixed several tests to align with new method signatures and behavior changes. --- cmd/soundtouch-service/main.go | 2 + doc.go | 100 +++------ pkg/service/handlers/handlers_media_test.go | 4 +- pkg/service/handlers/handlers_setup.go | 107 ++++++++-- pkg/service/handlers/handlers_setup_test.go | 48 +++++ pkg/service/handlers/main_test.go | 2 + pkg/service/handlers/web/index.html | 13 +- pkg/service/handlers/web/js/script.js | 95 ++++++++- pkg/service/setup/setup.go | 222 +++++++++++++++----- pkg/service/setup/setup_test.go | 140 +++++++++++- 10 files changed, 580 insertions(+), 153 deletions(-) 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

@@ -58,6 +58,11 @@
+ +
- + + + 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) }