From 8a21db3517165716acc0a4daf4cf838273080cc0 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 15 Feb 2026 20:15:54 +0100 Subject: [PATCH] Capture additional redirect methods and improve recorder functionality --- docs/analysis/DEVICE-REDIRECT-METHODS.md | 5 +- pkg/service/handlers/web/css/style.css | 7 +++ pkg/service/handlers/web/index.html | 38 ++++++++++++-- pkg/service/handlers/web/js/script.js | 65 ++++++++++++++++++++++++ pkg/service/proxy/recorder.go | 1 + pkg/service/proxy/recorder_test.go | 6 +++ pkg/service/setup/setup.go | 50 +++++++++++++++--- pkg/service/setup/setup_test.go | 54 ++++++++++++++++++++ 8 files changed, 215 insertions(+), 11 deletions(-) diff --git a/docs/analysis/DEVICE-REDIRECT-METHODS.md b/docs/analysis/DEVICE-REDIRECT-METHODS.md index 8bb51c8..9cbc868 100644 --- a/docs/analysis/DEVICE-REDIRECT-METHODS.md +++ b/docs/analysis/DEVICE-REDIRECT-METHODS.md @@ -9,6 +9,9 @@ SoundTouch devices primarily communicate with the following domains: - `updates.bose.com`: Software updates - `stats.bose.com`: Telemetry and analytics - `bmx.bose.com`: Bose Media eXchange registry +- `events.api.bosecm.com`: Stockholm app analytics +- `bose-prod.apigee.net`: Apigee gateway (used by some services) +- `worldwide.bose.com`: Software update metadata and secondary services --- @@ -153,7 +156,7 @@ For developers creating a completely isolated "dark" environment (no internet at 1. **XML**: Point all URLs to local services. 2. **Binary Patch**: Neutralize `IsItBose` to allow non-Bose domains/IPs. 3. **`/etc/hosts`**: Redirect hardcoded domains that aren't exposed in the XML (like analytics or NTP) to prevent leakage to the real Bose cloud. -4. **Process Instrumentation**: Use [SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook) to monitor and override internal behavior in real-time. +4. **Process Instrumentation**: Use [SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook) to monitor and override internal behavior in real-time. This is particularly useful for handling unknown hostnames or deep-hooking into service discovery logic that might bypass standard DNS lookups. --- diff --git a/pkg/service/handlers/web/css/style.css b/pkg/service/handlers/web/css/style.css index de568e1..ad2b4d5 100644 --- a/pkg/service/handlers/web/css/style.css +++ b/pkg/service/handlers/web/css/style.css @@ -102,3 +102,10 @@ pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px; .category-upstream { background-color: #f3e5f5; color: #7b1fa2; } .status-success { background-color: #e8f5e9; color: #2e7d32; } .status-error { background-color: #ffebee; color: #c62828; } + +.badge { + padding: 2px 8px; + border-radius: 10px; + font-size: 0.8em; + font-weight: bold; +} diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index 0b1a5ee..c20a4e7 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -17,7 +17,7 @@ - + @@ -281,15 +281,18 @@ - +
-

Recorded Interactions

-

Analysis of traffic handled by this service (self) and proxied to Bose (upstream).

+

Recorded Interactions & Device Events

+

Analysis of traffic handled by this service (self), proxied to Bose (upstream), and internal device events (telemetry).

Total Requests: 0

+
+ +
Keeps only the 10 most recent sessions
@@ -360,6 +363,33 @@

             
+ + +
diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js index aaaae04..ce026b1 100644 --- a/pkg/service/handlers/web/js/script.js +++ b/pkg/service/handlers/web/js/script.js @@ -97,8 +97,12 @@ async function fetchDevices() { // Clear and repopulate selectors const currentSyncVal = syncSelector.value; const currentMigrationVal = migrationSelector.value; + const eventSelector = document.getElementById('event-device-selector'); + const currentEventVal = eventSelector ? eventSelector.value : ""; + syncSelector.innerHTML = ''; migrationSelector.innerHTML = ''; + if (eventSelector) eventSelector.innerHTML = ''; devices.forEach(d => { const methodLabel = d.discovery_method === 'manual' ? '👤 Manual' : '🔍 Auto'; @@ -126,12 +130,20 @@ async function fetchDevices() { optMigrate.value = d.ip_address; optMigrate.textContent = `${d.name} (${d.ip_address})`; migrationSelector.appendChild(optMigrate); + + if (eventSelector) { + const optEvent = document.createElement('option'); + optEvent.value = d.device_id || d.ip_address; + optEvent.textContent = `${d.name} (${d.ip_address})`; + eventSelector.appendChild(optEvent); + } }); html += ''; container.innerHTML = html; if (currentSyncVal) syncSelector.value = currentSyncVal; if (currentMigrationVal) migrationSelector.value = currentMigrationVal; + if (eventSelector && currentEventVal) eventSelector.value = currentEventVal; // Asynchronously fetch live info for each device devices.forEach(d => updateDeviceInfo(d.ip_address)); @@ -478,6 +490,59 @@ async function viewInteraction(file) { } } +async function showDeviceEvents() { + const overlay = document.getElementById('device-events-overlay'); + overlay.style.display = 'block'; + overlay.scrollIntoView({ behavior: 'smooth' }); + + // Ensure device selector is populated (handled by fetchDevices) + // but if it's still empty, we can try to trigger a fetch + const selector = document.getElementById('event-device-selector'); + if (selector.options.length <= 1) { + fetchDevices(); + } +} + +async function fetchDeviceEvents(deviceId) { + if (!deviceId) return; + + const list = document.getElementById('events-list'); + list.innerHTML = 'Loading events...'; + + try { + const response = await fetch(`/setup/devices/${deviceId}/events`); + const data = await response.json(); + const events = data.events; + + list.innerHTML = ''; + if (!events || events.length === 0) { + list.innerHTML = 'No events found for this device.'; + return; + } + + // Sort events by time descending + events.sort((a, b) => (b.time || "").localeCompare(a.time || "")); + + events.forEach(e => { + const tr = document.createElement('tr'); + tr.style.borderBottom = '1px solid #eee'; + + const time = e.time || ""; + const type = e.type || ""; + const data = JSON.stringify(e.data || {}); + + tr.innerHTML = ` + ${time} + ${type} + ${data} + `; + list.appendChild(tr); + }); + } catch (error) { + list.innerHTML = `Error loading events: ${error.message}`; + } +} + document.addEventListener('DOMContentLoaded', () => { fetchSettings(); fetchDevices(); diff --git a/pkg/service/proxy/recorder.go b/pkg/service/proxy/recorder.go index c482da4..43f016b 100644 --- a/pkg/service/proxy/recorder.go +++ b/pkg/service/proxy/recorder.go @@ -141,6 +141,7 @@ func (r *Recorder) writeRequest(buf *bytes.Buffer, req *http.Request, replacemen } fmt.Fprintf(buf, "%s %s\n", req.Method, displayURL) + fmt.Fprintf(buf, "Host: %s\n", req.Host) for k, vv := range req.Header { if r.Redact && isSensitive(k) { diff --git a/pkg/service/proxy/recorder_test.go b/pkg/service/proxy/recorder_test.go index 6955bec..2c849bb 100644 --- a/pkg/service/proxy/recorder_test.go +++ b/pkg/service/proxy/recorder_test.go @@ -93,6 +93,12 @@ func TestRecorder_Record_Structure(t *testing.T) { if len(f.Name()) < 5 || !isDigit(f.Name()[0]) || !isDigit(f.Name()[1]) || !isDigit(f.Name()[2]) || !isDigit(f.Name()[3]) || f.Name()[4] != '-' { t.Errorf("Filename %s does not have correct 0000- prefix", f.Name()) } + + // Verify Host header is present + content, _ := os.ReadFile(filepath.Join(expectedDir, f.Name())) + if !strings.Contains(string(content), "Host: ") { + t.Errorf("Recorded file does not contain Host header:\n%s", string(content)) + } } }) } diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index 01bc60b..8bd0c32 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -215,6 +215,9 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti "stats.bose.com", "bmx.bose.com", "content.api.bose.io", + "events.api.bosecm.com", + "bose-prod.apigee.net", + "worldwide.bose.com", } var hostsLines []string @@ -858,6 +861,9 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) { "stats.bose.com", "bmx.bose.com", "content.api.bose.io", + "events.api.bosecm.com", + "bose-prod.apigee.net", + "worldwide.bose.com", } hostsContent, err := client.Run("cat /etc/hosts") @@ -867,16 +873,48 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) { return logs, fmt.Errorf("failed to read /etc/hosts: %w", err) } - for _, domain := range domains { - if !strings.Contains(hostsContent, domain) { - entry := fmt.Sprintf("%s\t%s", hostIP, domain) + lines := strings.Split(hostsContent, "\n") + var newLines []string + domainFound := make(map[string]bool) - if hostsContent != "" && !strings.HasSuffix(hostsContent, "\n") { - hostsContent += "\n" + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + newLines = append(newLines, line) + continue + } + + fields := strings.Fields(trimmed) + if len(fields) >= 2 { + domain := fields[1] + isBoseDomain := false + for _, d := range domains { + if d == domain { + isBoseDomain = true + break + } } - hostsContent += entry + "\n" + if isBoseDomain { + // Update existing entry with new IP + newLines = append(newLines, fmt.Sprintf("%s\t%s", hostIP, domain)) + domainFound[domain] = true + continue + } } + newLines = append(newLines, line) + } + + // Add missing domains + for _, domain := range domains { + if !domainFound[domain] { + newLines = append(newLines, fmt.Sprintf("%s\t%s", hostIP, domain)) + } + } + + hostsContent = strings.Join(newLines, "\n") + if !strings.HasSuffix(hostsContent, "\n") { + hostsContent += "\n" } // 3. Upload new /etc/hosts diff --git a/pkg/service/setup/setup_test.go b/pkg/service/setup/setup_test.go index 9628835..f100edc 100644 --- a/pkg/service/setup/setup_test.go +++ b/pkg/service/setup/setup_test.go @@ -109,6 +109,60 @@ func TestMigrateViaHosts(t *testing.T) { } } +func TestMigrateViaHosts_UpdateExisting(t *testing.T) { + tempDir, err := os.MkdirTemp("", "setup-test-update") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs")) + _ = cm.EnsureCA() + + m := NewManager("http://192.168.1.100:8000", nil, cm) + + m.NewSSH = func(host string) SSHClient { + return &mockSSH{ + runFunc: func(command string) (string, error) { + if command == "cat /etc/hosts" { + return "127.0.0.1 localhost\n1.2.3.4\tstreaming.bose.com\n1.2.3.4\tupdates.bose.com", nil + } + if strings.HasPrefix(command, "[ -f") { + return "", nil // Backup already exists + } + if strings.HasPrefix(command, "grep -F") { + return "matched", nil // CA already trusted + } + return "", nil + }, + uploadContentFunc: func(content []byte, remotePath string) error { + if remotePath == "/etc/hosts" { + c := string(content) + if !strings.Contains(c, "192.168.1.100\tstreaming.bose.com") { + t.Errorf("Expected updated IP for streaming.bose.com, got:\n%s", c) + } + if !strings.Contains(c, "192.168.1.100\tupdates.bose.com") { + t.Errorf("Expected updated IP for updates.bose.com, got:\n%s", c) + } + if !strings.Contains(c, "192.168.1.100\tevents.api.bosecm.com") { + t.Errorf("Expected new domain events.api.bosecm.com, got:\n%s", c) + } + // Ensure no duplicates + if strings.Count(c, "streaming.bose.com") != 1 { + t.Errorf("Expected streaming.bose.com to appear exactly once, got %d", strings.Count(c, "streaming.bose.com")) + } + } + return nil + }, + } + } + + _, err = m.migrateViaHosts("192.168.1.10", "http://192.168.1.100:8000") + if err != nil { + t.Fatalf("migrateViaHosts failed: %v", err) + } +} + func TestGetLiveDeviceInfo(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/info" {