mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 09:06:14 +00:00
Implement label-based CA certificate management and add timeout flags to curl commands
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<p id="original-config-status" style="display: none;">Backup: ✅ Found .original config at <code>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code> <button onclick="toggleOriginalConfig()">Show Original Config</button></p>
|
||||
<p id="no-original-config-status" style="display: none;">Backup: ❌ Not found <button id="backup-config-btn">Backup Config Now</button></p>
|
||||
<p>Remote Services Enabled: <span id="remote-services-status"></span> <span id="remote-services-found" style="font-size: 0.8em; color: #666;"></span></p>
|
||||
<p>Local Root CA Trusted: <span id="ca-trust-status"></span></p>
|
||||
<p>Local Root CA Trusted: <span id="ca-trust-status"></span> <button id="trust-ca-btn" style="display: none; background-color: #607D8B; color: white; border: none; padding: 2px 8px; font-size: 0.8em; margin-left: 10px;">Trust CA Now</button></p>
|
||||
|
||||
<div id="connection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #eefbff;">
|
||||
<strong>HTTPS Connection Test:</strong><br>
|
||||
|
||||
@@ -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.');
|
||||
|
||||
+81
-28
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user