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.
This commit is contained in:
Tobias Gesellchen
2026-02-14 14:24:46 +01:00
parent e084f8db1f
commit 93cfd9dbbc
10 changed files with 580 additions and 153 deletions
+2
View File
@@ -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)
+24 -76
View File
@@ -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
+2 -2
View File
@@ -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))
}
}
+92 -15
View File
@@ -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")
@@ -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{}
+2
View File
@@ -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)
+10 -3
View File
@@ -2,12 +2,12 @@
<html>
<head>
<meta charset="UTF-8">
<title>Soundcork Management</title>
<title>Bose SoundTouch Toolkit</title>
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
<link rel="stylesheet" href="/web/css/style.css">
</head>
<body>
<h1>Soundcork Management</h1>
<h1>Bose SoundTouch Toolkit</h1>
<div class="tabs">
<div class="tab-buttons">
@@ -58,6 +58,11 @@
<div id="status" class="status"></div>
<div id="command-output-box" class="summary-box" style="display: none; background-color: #f0f0f0;">
<h3>Command Output</h3>
<div id="command-output" style="font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 300px; overflow-y: auto; padding: 10px; border: 1px solid #ccc; background: #fff;"></div>
</div>
<div id="migration-summary" class="summary-box" style="display: none;">
<h3>Migration Summary for <span id="summary-ip"></span></h3>
<p>SSH Connection: <span id="ssh-status"></span></p>
@@ -169,7 +174,9 @@
</div>
</div>
<div style="margin-top: 15px;">
<button id="confirm-migrate-btn" style="background-color: #4CAF50; color: white; border: none; padding: 10px 20px;">Confirm Migration & Reboot</button>
<button id="confirm-migrate-btn" style="background-color: #4CAF50; color: white; border: none; padding: 10px 20px;">Confirm Migration</button>
<button id="revert-migrate-btn" style="background-color: #FF9800; color: white; border: none; padding: 10px 20px; display: none;">Revert to Defaults</button>
<button id="reboot-speaker-btn" style="background-color: #607D8B; color: white; border: none; padding: 10px 20px;">Reboot Speaker</button>
<button id="ensure-remote-btn" style="background-color: #2196F3; color: white; border: none; padding: 10px 20px;">Enable Persistent Remote Services</button>
<button id="remove-remote-btn" style="background-color: #f44336; color: white; border: none; padding: 10px 20px;">Remove Persistent Remote Services</button>
<button onclick="document.getElementById('migration-summary').style.display='none'" style="padding: 10px 20px;">Cancel</button>
+94 -1
View File
@@ -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 + '.';
+172 -50
View File
@@ -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), &currentCfg) == 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.
+134 -6
View File
@@ -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)
}