mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
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:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user