Capture additional redirect methods and improve recorder functionality

This commit is contained in:
Tobias Gesellchen
2026-02-15 20:20:47 +01:00
parent 742484568e
commit 8a21db3517
8 changed files with 215 additions and 11 deletions
+4 -1
View File
@@ -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.
---
+7
View File
@@ -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;
}
+34 -4
View File
@@ -17,7 +17,7 @@
<button class="tab-btn" onclick="openTab(event, 'tab-devices')">2. Devices</button>
<button class="tab-btn" onclick="openTab(event, 'tab-sync')">3. Data Sync</button>
<button class="tab-btn" onclick="openTab(event, 'tab-migration')">4. Migration</button>
<button class="tab-btn" onclick="openTab(event, 'tab-interactions')">5. Interactions</button>
<button class="tab-btn" onclick="openTab(event, 'tab-interactions')">5. Interactions & Events</button>
</div>
<!-- Tab 0: Overview -->
@@ -281,15 +281,18 @@
</div>
</div>
<!-- Tab 5: Interactions -->
<!-- Tab 5: Interactions & Events -->
<div id="tab-interactions" class="tab-content">
<h2>Recorded Interactions</h2>
<p>Analysis of traffic handled by this service (self) and proxied to Bose (upstream).</p>
<h2>Recorded Interactions & Device Events</h2>
<p>Analysis of traffic handled by this service (self), proxied to Bose (upstream), and internal device events (telemetry).</p>
<div id="interaction-stats-container" class="summary-box">
<div style="display: flex; gap: 20px; align-items: center; margin-bottom: 15px;">
<p style="margin: 0;">Total Requests: <strong id="total-requests">0</strong></p>
<button onclick="fetchInteractionStats()">Refresh Stats</button>
<div style="margin-left: 10px;">
<button onclick="showDeviceEvents()">View App/Device Events</button>
</div>
<div style="margin-left: auto; text-align: right;">
<button onclick="cleanupSessions()" class="btn-danger">Cleanup old sessions</button>
<div style="font-size: 0.75em; color: #666; margin-top: 3px;">Keeps only the 10 most recent sessions</div>
@@ -360,6 +363,33 @@
</div>
<pre id="interaction-content" style="white-space: pre-wrap; font-family: 'Courier New', Courier, monospace; font-size: 0.9em; margin: 0; padding: 10px; overflow-x: auto; max-height: 600px;"></pre>
</div>
<!-- Device Events Overlay -->
<div id="device-events-overlay" class="summary-box" style="margin-top: 20px; display: none; background: #fdfdfd; border: 1px solid #ddd;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0;">App & Device Events</h3>
<div>
<select id="event-device-selector" onchange="fetchDeviceEvents(this.value)">
<option value="">-- Select Device --</option>
</select>
<button onclick="document.getElementById('device-events-overlay').style.display='none'" style="margin-left: 10px;">Close</button>
</div>
</div>
<div id="events-list-container" style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 2px solid #eee;">
<th style="padding: 8px;">Time</th>
<th style="padding: 8px;">Type</th>
<th style="padding: 8px;">Data</th>
</tr>
</thead>
<tbody id="events-list">
<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Select a device to view events.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
+65
View File
@@ -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 = '<option value="">-- Select a device --</option>';
migrationSelector.innerHTML = '<option value="">-- Select a device --</option>';
if (eventSelector) eventSelector.innerHTML = '<option value="">-- Select a device --</option>';
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 += '</table>';
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 = '<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Loading events...</td></tr>';
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 = '<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">No events found for this device.</td></tr>';
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 = `
<td style="padding: 8px; font-size: 0.8em; white-space: nowrap;">${time}</td>
<td style="padding: 8px;"><span class="badge category-self">${type}</span></td>
<td style="padding: 8px; font-size: 0.85em; font-family: monospace; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title='${data}'>${data}</td>
`;
list.appendChild(tr);
});
} catch (error) {
list.innerHTML = `<tr><td colspan="3" style="padding: 20px; text-align: center; color: #f44336;">Error loading events: ${error.message}</td></tr>`;
}
}
document.addEventListener('DOMContentLoaded', () => {
fetchSettings();
fetchDevices();
+1
View File
@@ -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) {
+6
View File
@@ -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))
}
}
})
}
+44 -6
View File
@@ -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
+54
View File
@@ -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" {