Implement label-based CA certificate management and add timeout flags to curl commands

This commit is contained in:
Tobias Gesellchen
2026-02-13 22:36:36 +01:00
parent 408753c33e
commit c9f648096e
8 changed files with 256 additions and 37 deletions
+35
View File
@@ -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{}
+1
View File
@@ -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)
+1 -1
View File
@@ -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>
+29
View File
@@ -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
View File
@@ -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
}
+90 -8
View File
@@ -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)
}