diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go
index 2c87670..43d25d1 100644
--- a/cmd/soundtouch-service/main.go
+++ b/cmd/soundtouch-service/main.go
@@ -276,6 +276,7 @@ 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("/trust-ca/{deviceIP}", server.HandleTrustCACert)
r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices)
r.Post("/backup/{deviceIP}", server.HandleBackupConfig)
diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go
index 52d2529..1bf3dbf 100644
--- a/pkg/service/handlers/handlers_setup.go
+++ b/pkg/service/handlers/handlers_setup.go
@@ -158,6 +158,41 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
}
}
+// HandleTrustCACert injects the local Root CA into the device's shared trust store.
+func (s *Server) HandleTrustCACert(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
+ }
+
+ if err := s.sm.TrustCACert(deviceIP); 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 {
+ 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": "Root CA trusted"}); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ return
+ }
+}
+
// HandleEnsureRemoteServices ensures that remote services are configured on a device.
func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
diff --git a/pkg/service/handlers/handlers_setup_test.go b/pkg/service/handlers/handlers_setup_test.go
index 76822b3..05cda96 100644
--- a/pkg/service/handlers/handlers_setup_test.go
+++ b/pkg/service/handlers/handlers_setup_test.go
@@ -144,6 +144,24 @@ func TestMigrationAndCA(t *testing.T) {
if result["ok"] != true {
t.Errorf("Migrate: Expected ok=true, got %v", result["ok"])
}
+
+ // 3. Test POST /setup/trust-ca/{deviceIP}
+ res, err = http.Post(ts.URL+"/setup/trust-ca/192.168.1.10", "application/json", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ t.Errorf("TrustCA: Expected status OK, got %v", res.Status)
+ }
+
+ if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
+ t.Fatalf("TrustCA: Failed to decode response: %v", err)
+ }
+ if result["ok"] != true {
+ t.Errorf("TrustCA: Expected ok=true, got %v", result["ok"])
+ }
}
type mockSSH struct{}
diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go
index a8d8f8b..9dda9aa 100644
--- a/pkg/service/handlers/main_test.go
+++ b/pkg/service/handlers/main_test.go
@@ -52,6 +52,7 @@ 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("/trust-ca/{deviceIP}", server.HandleTrustCACert)
r.Post("/test-connection/{deviceIP}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection)
r.Get("/ca.crt", server.HandleGetCACert)
diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html
index ddad6d1..9a15f4e 100644
--- a/pkg/service/handlers/web/index.html
+++ b/pkg/service/handlers/web/index.html
@@ -42,7 +42,7 @@
Backup: ✅ Found .original config at /opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original
Backup: ❌ Not found
Remote Services Enabled:
- Local Root CA Trusted:
+ Local Root CA Trusted:
HTTPS Connection Test:
diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js
index 1c869aa..91202f4 100644
--- a/pkg/service/handlers/web/js/script.js
+++ b/pkg/service/handlers/web/js/script.js
@@ -203,6 +203,8 @@ async function showSummary(ip) {
const caTrustStatus = document.getElementById('ca-trust-status');
caTrustStatus.innerText = summary.ca_cert_trusted ? '✅ Yes' : '❌ No';
caTrustStatus.style.color = summary.ca_cert_trusted ? 'green' : 'red';
+ document.getElementById('trust-ca-btn').style.display = summary.ca_cert_trusted ? 'none' : 'inline-block';
+ document.getElementById('trust-ca-btn').onclick = () => trustCA(ip);
} else {
remoteStatus.innerText = '❓ Unknown';
remoteStatus.style.color = 'gray';
@@ -308,6 +310,33 @@ async function migrate(ip) {
}
}
+async function trustCA(ip) {
+ if (!ip) {
+ alert('Please enter a valid IP address.');
+ return;
+ }
+ const statusDiv = document.getElementById('status');
+ statusDiv.style.display = 'block';
+ statusDiv.style.backgroundColor = '#ffffcc';
+ statusDiv.innerHTML = 'Injecting Root CA into shared trust store on ' + ip + '...';
+
+ try {
+ const response = await fetch('/setup/trust-ca/' + ip, { method: 'POST' });
+ const result = await response.json();
+ if (result.ok) {
+ statusDiv.style.backgroundColor = '#ccffcc';
+ statusDiv.innerHTML = 'Successfully injected Root CA on ' + ip + '.';
+ showSummary(ip); // Refresh to update status
+ } else {
+ statusDiv.style.backgroundColor = '#ffcccc';
+ statusDiv.innerHTML = 'Failed to trust CA on ' + ip + ': ' + (result.message || 'Unknown error');
+ }
+ } catch (error) {
+ statusDiv.style.backgroundColor = '#ffcccc';
+ statusDiv.innerHTML = 'Error trusting CA on ' + ip + ': ' + error;
+ }
+}
+
async function ensureRemoteServices(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go
index 722d35b..55cb243 100644
--- a/pkg/service/setup/setup.go
+++ b/pkg/service/setup/setup.go
@@ -380,6 +380,16 @@ func (m *Manager) checkCACertTrusted(summary *MigrationSummary, deviceIP string)
return
}
+ client := m.NewSSH(deviceIP)
+ bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
+
+ // First, check for the label
+ output, err := client.Run(fmt.Sprintf("grep -F %q %s", CALabel, bundlePath))
+ if err == nil && strings.Contains(output, CALabel) {
+ summary.CACertTrusted = true
+ return
+ }
+
caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
if err != nil {
return
@@ -402,8 +412,6 @@ func (m *Manager) checkCACertTrusted(summary *MigrationSummary, deviceIP string)
return
}
- client := m.NewSSH(deviceIP)
- bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
// Use grep to check for the certificate data in the bundle
_, err = client.Run(fmt.Sprintf("grep -F %q %s", certData, bundlePath))
if err == nil {
@@ -568,6 +576,69 @@ func (m *Manager) EnsureRemoteServices(deviceIP string) error {
return 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 {
+ client := m.NewSSH(deviceIP)
+ rwCmd := "(rw || mount -o remount,rw /)"
+
+ caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
+ if err != nil {
+ return fmt.Errorf("failed to read CA certificate: %w", err)
+ }
+
+ bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
+ _, _ = client.Run(rwCmd)
+
+ // Backup bundle if it doesn't exist
+ if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", bundlePath)); err != nil {
+ _, _ = client.Run(fmt.Sprintf("cp %s %s.original", bundlePath, bundlePath))
+ }
+
+ // Check if the label already exists in the bundle
+ bundleContent, _ := client.Run(fmt.Sprintf("cat %s", bundlePath))
+ 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.
+ // For simplicity, let's remove everything between CALabel tags if we had them,
+ // but since we only had one line before, let's just remove lines containing CALabel
+ // and the cert data if possible.
+ // A better way is to rebuild the bundle without our CA.
+ 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"
+ }
+ } else if bundleContent != "" && !strings.HasSuffix(bundleContent, "\n") {
+ bundleContent += "\n"
+ }
+
+ // Append with labels
+ labeledCert := fmt.Sprintf("\n%s\n%s%s\n", CALabel, string(caCertPEM), CALabel)
+ newBundleContent := bundleContent + labeledCert
+
+ if err := client.UploadContent([]byte(newBundleContent), bundlePath); err != nil {
+ return fmt.Errorf("failed to update bundle: %w", err)
+ }
+
+ return nil
+}
+
func (m *Manager) migrateViaHosts(deviceIP, targetURL string) error {
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
@@ -630,29 +701,8 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) error {
m.checkCACertTrusted(summary, deviceIP)
if !summary.CACertTrusted {
- caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
- if err != nil {
- return fmt.Errorf("failed to read CA certificate: %w", err)
- }
-
- // Append to bundle
- bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
- _, _ = client.Run(rwCmd)
-
- // Backup bundle if it doesn't exist
- if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", bundlePath)); err != nil {
- _, _ = client.Run(fmt.Sprintf("cp %s %s.original", bundlePath, bundlePath))
- }
-
- // We use session.Run for append or similar, but client.Run uses CombinedOutput.
- // Let's use a temporary file and append it.
- tmpCertPath := "/tmp/local-ca.crt"
- if err := client.UploadContent(caCertPEM, tmpCertPath); err != nil {
- return fmt.Errorf("failed to upload CA cert to tmp: %w", err)
- }
-
- if _, err := client.Run(fmt.Sprintf("%s && cat %s >> %s && rm %s", rwCmd, tmpCertPath, bundlePath, tmpCertPath)); err != nil {
- return fmt.Errorf("failed to append CA cert to bundle: %w", err)
+ if err := m.TrustCACert(deviceIP); err != nil {
+ return err
}
} else {
fmt.Printf("CA certificate already trusted on %s, skipping injection\n", deviceIP)
@@ -701,6 +751,9 @@ func (m *Manager) RemoveRemoteServices(deviceIP string) error {
// TestDomain is the fake domain used for preliminary redirection tests.
const TestDomain = "custom-test-api.bose.fake"
+// CALabel is the label used to identify the local CA certificate in the trust store.
+const CALabel = "# Soundcork Local Root CA"
+
// TestHostsRedirection performs a preliminary check to see if /etc/hosts redirection works.
func (m *Manager) TestHostsRedirection(deviceIP, targetURL string) (string, error) {
client := m.NewSSH(deviceIP)
@@ -815,7 +868,7 @@ func (m *Manager) runHTTPRedirectionTest(client SSHClient, parsedURL *url.URL, t
httpTestURL = fmt.Sprintf("http://%s/health", testDomain)
}
- cmd := fmt.Sprintf("curl -v -s -L %s", httpTestURL)
+ cmd := fmt.Sprintf("curl --max-time 15 --connect-timeout 10 -v -s -L %s", httpTestURL)
output, err := client.Run(cmd)
if err != nil {
@@ -850,7 +903,7 @@ func (m *Manager) runHTTPSRedirectionTest(client SSHClient, testDomain string) (
_, _ = client.Run("rm " + caPath)
}()
- httpsCmd := fmt.Sprintf("curl -v -s -L --cacert %s %s", caPath, httpsTestURL)
+ httpsCmd := fmt.Sprintf("curl --max-time 15 --connect-timeout 10 -v -s -L --cacert %s %s", caPath, httpsTestURL)
return client.Run(httpsCmd)
}
@@ -878,7 +931,7 @@ func (m *Manager) TestConnection(deviceIP, targetURL string, useExplicitCA bool)
}()
}
- cmd := fmt.Sprintf("curl -v -s -L %s", targetURL)
+ cmd := fmt.Sprintf("curl --max-time 15 --connect-timeout 10 -v -s -L %s", targetURL)
if useExplicitCA {
cmd += " --cacert " + caPath
}
diff --git a/pkg/service/setup/setup_test.go b/pkg/service/setup/setup_test.go
index 3f43e9c..a9d78fe 100644
--- a/pkg/service/setup/setup_test.go
+++ b/pkg/service/setup/setup_test.go
@@ -230,12 +230,12 @@ func TestCheckCACertTrusted(t *testing.T) {
m := NewManager("http://localhost:8000", nil, cm)
- // Mock SSH to return "found" for grep
+ // Test 1: Found via label
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
- if strings.HasPrefix(command, "grep -F") {
- return "found", nil
+ if strings.HasPrefix(command, "grep -F") && strings.Contains(command, CALabel) {
+ return CALabel, nil
}
return "", nil
},
@@ -244,12 +244,33 @@ func TestCheckCACertTrusted(t *testing.T) {
summary := &MigrationSummary{}
m.checkCACertTrusted(summary, "192.168.1.10")
-
if !summary.CACertTrusted {
- t.Errorf("Expected CACertTrusted to be true, got false")
+ t.Errorf("Expected CACertTrusted to be true when label is found")
}
- // Mock SSH to return "not found" (error) for grep
+ // Test 2: Found via data snippet (label missing)
+ m.NewSSH = func(host string) SSHClient {
+ return &mockSSH{
+ runFunc: func(command string) (string, error) {
+ if strings.HasPrefix(command, "grep -F") {
+ if strings.Contains(command, CALabel) {
+ return "", fmt.Errorf("not found")
+ }
+ // Searching for cert data
+ return "found data", nil
+ }
+ return "", nil
+ },
+ }
+ }
+
+ summary = &MigrationSummary{}
+ m.checkCACertTrusted(summary, "192.168.1.10")
+ if !summary.CACertTrusted {
+ t.Errorf("Expected CACertTrusted to be true when cert data is found")
+ }
+
+ // Test 3: Not found
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
@@ -263,9 +284,8 @@ func TestCheckCACertTrusted(t *testing.T) {
summary = &MigrationSummary{}
m.checkCACertTrusted(summary, "192.168.1.10")
-
if summary.CACertTrusted {
- t.Errorf("Expected CACertTrusted to be false, got true")
+ t.Errorf("Expected CACertTrusted to be false when nothing is found")
}
}
@@ -552,6 +572,68 @@ func TestMigrateViaHosts_SkipCAIfTrusted(t *testing.T) {
}
}
+func TestTrustCACert(t *testing.T) {
+ tempDir, err := os.MkdirTemp("", "trust-ca-test")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tempDir)
+
+ cm := crypto.NewCertificateManager(filepath.Join(tempDir, "certs"))
+ if err := cm.EnsureCA(); err != nil {
+ t.Fatalf("Failed to ensure CA: %v", err)
+ }
+
+ m := NewManager("http://localhost:8000", nil, cm)
+
+ runCalls := []string{}
+ uploadCalls := []string{}
+ m.NewSSH = func(host string) SSHClient {
+ return &mockSSH{
+ runFunc: func(command string) (string, error) {
+ runCalls = append(runCalls, command)
+ if strings.HasPrefix(command, "[ -f") {
+ return "", fmt.Errorf("file not found")
+ }
+ return "", nil
+ },
+ uploadContentFunc: func(content []byte, remotePath string) error {
+ uploadCalls = append(uploadCalls, remotePath)
+ return nil
+ },
+ }
+ }
+
+ err = m.TrustCACert("192.168.1.10")
+ if err != nil {
+ t.Fatalf("TrustCACert failed: %v", err)
+ }
+
+ // Verify CA backup and injection
+ foundBackup := false
+ for _, call := range runCalls {
+ if strings.Contains(call, "cp /etc/pki/tls/certs/ca-bundle.crt /etc/pki/tls/certs/ca-bundle.crt.original") {
+ foundBackup = true
+ }
+ }
+
+ if !foundBackup {
+ t.Errorf("Expected ca-bundle.crt backup")
+ }
+
+ // Verify CA upload
+ foundUpload := false
+ for _, path := range uploadCalls {
+ if path == "/etc/pki/tls/certs/ca-bundle.crt" {
+ foundUpload = true
+ break
+ }
+ }
+ if !foundUpload {
+ t.Errorf("Expected updated bundle to be uploaded to /etc/pki/tls/certs/ca-bundle.crt")
+ }
+}
+
func contains(s, substr string) bool {
return strings.Contains(s, substr)
}