From c9f648096e5be5d5302e429d690d0cac286e32e7 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Fri, 13 Feb 2026 22:23:54 +0100 Subject: [PATCH] Implement label-based CA certificate management and add timeout flags to curl commands --- cmd/soundtouch-service/main.go | 1 + pkg/service/handlers/handlers_setup.go | 35 +++++++ pkg/service/handlers/handlers_setup_test.go | 18 ++++ pkg/service/handlers/main_test.go | 1 + pkg/service/handlers/web/index.html | 2 +- pkg/service/handlers/web/js/script.js | 29 ++++++ pkg/service/setup/setup.go | 109 +++++++++++++++----- pkg/service/setup/setup_test.go | 98 ++++++++++++++++-- 8 files changed, 256 insertions(+), 37 deletions(-) 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 @@

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) }