diff --git a/README.md b/README.md
index 099da5d..64b47d7 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,8 @@ A comprehensive solution for controlling and preserving Bose SoundTouch devices,
- ๐ **DNS Discovery Analysis**: Track and deduplicate all device DNS queries to discover hidden hostnames
- ๐ **Traffic Analysis**: Proxy and log device communications
- ๐ **HTTP Recording**: Persist interactions as re-playable `.http` files
+- ๐ **Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
+- โ๏ธ **Parity Logging**: Detect and record discrepancies between local and official Bose responses
- ๐งน **Session Management**: Manage and cleanup recorded interaction sessions
- ๐ **Production Ready**: Extensive testing with real SoundTouch hardware
- ๐ **Cross-Platform**: Windows, macOS, Linux support
@@ -77,6 +79,8 @@ The `soundtouch-service` is a local server that emulates Bose's cloud services.
- **๐ง Device Migration**: Seamlessly transition devices to local control
- **๐ Web Management UI**: Easy browser-based setup and management
- **๐พ Persistent Data**: Store presets, recents, and sources locally
+- **๐ Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
+- **โ๏ธ Parity Logging**: Detect and record discrepancies between local and official Bose responses
- **๐ HTTP Recording**: Persist all interactions as re-playable `.http` files
- **๐งน Session Management**: Manage and cleanup recorded interaction sessions
diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go
index 07cf977..ed85ce5 100644
--- a/cmd/soundtouch-service/main.go
+++ b/cmd/soundtouch-service/main.go
@@ -189,6 +189,21 @@ func main() {
Usage: "External base URL for OAuth callbacks behind reverse proxy",
EnvVars: []string{"BASE_URL"},
},
+ &cli.BoolFlag{
+ Name: "mirror-enabled",
+ Usage: "Enable background mirroring to Bose Cloud",
+ EnvVars: []string{"MIRROR_ENABLED"},
+ },
+ &cli.StringSliceFlag{
+ Name: "mirror-endpoints",
+ Usage: "Endpoints to mirror to Bose Cloud (comma-separated or multiple flags)",
+ EnvVars: []string{"MIRROR_ENDPOINTS"},
+ },
+ &cli.StringSliceFlag{
+ Name: "internal-paths",
+ Usage: "Paths for internal requests (comma-separated or multiple flags)",
+ EnvVars: []string{"INTERNAL_PATHS"},
+ },
},
Action: func(c *cli.Context) error {
config := loadConfig(c)
@@ -220,6 +235,8 @@ func main() {
server.SetVersionInfo(version, commit, date)
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
+ server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints)
+ server.SetInternalPaths(persisted.InternalPaths)
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
@@ -351,6 +368,9 @@ type serviceConfig struct {
dnsEnabled bool
dnsUpstream string
dnsBind string
+ mirrorEnabled bool
+ mirrorEndpoints []string
+ internalPaths []string
discoveryInterval time.Duration
domains []string
spotifyClientID string
@@ -422,6 +442,10 @@ func loadConfig(c *cli.Context) serviceConfig {
mgmtUsername := c.String("mgmt-username")
mgmtPassword := c.String("mgmt-password")
+ mirrorEnabled := c.Bool("mirror-enabled")
+ mirrorEndpoints := c.StringSlice("mirror-endpoints")
+ internalPaths := c.StringSlice("internal-paths")
+
return serviceConfig{
port: port,
bindAddr: bindAddr,
@@ -438,6 +462,9 @@ func loadConfig(c *cli.Context) serviceConfig {
dnsEnabled: dnsEnabled,
dnsUpstream: dnsUpstream,
dnsBind: dnsBind,
+ mirrorEnabled: mirrorEnabled,
+ mirrorEndpoints: mirrorEndpoints,
+ internalPaths: internalPaths,
discoveryInterval: discoveryInterval,
domains: domains,
spotifyClientID: spotifyClientID,
@@ -515,6 +542,10 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
config.dnsBind = persisted.DNSBindAddr
}
+ config.mirrorEnabled = persisted.MirrorEnabled
+ config.mirrorEndpoints = persisted.MirrorEndpoints
+ config.internalPaths = persisted.InternalPaths
+
return persisted
}
@@ -532,6 +563,9 @@ func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datast
DNSEnabled: config.dnsEnabled,
DNSUpstream: strings.Split(config.dnsUpstream, ","),
DNSBindAddr: config.dnsBind,
+ MirrorEnabled: config.mirrorEnabled,
+ MirrorEndpoints: config.mirrorEndpoints,
+ InternalPaths: config.internalPaths,
Shortcuts: map[string]int{
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
"/sw.js": http.StatusNotFound,
@@ -578,6 +612,7 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Use(server.OriginMiddleware)
r.Use(middleware.Recoverer)
r.Use(server.ShortcutMiddleware)
+ r.Use(server.MirrorMiddleware)
r.Use(server.RecordMiddleware)
r.Get("/", server.HandleRoot)
@@ -719,6 +754,8 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/interaction-stats", server.HandleGetInteractionStats)
r.Get("/interactions", server.HandleListInteractions)
r.Get("/interaction-content", server.HandleGetInteractionContent)
+ r.Get("/parity-mismatches", server.HandleListParityMismatches)
+ r.Delete("/parity-mismatches", server.HandleClearParityMismatches)
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
diff --git a/docs/guides/SOUNDTOUCH-SERVICE.md b/docs/guides/SOUNDTOUCH-SERVICE.md
index eabaafe..1abad6c 100644
--- a/docs/guides/SOUNDTOUCH-SERVICE.md
+++ b/docs/guides/SOUNDTOUCH-SERVICE.md
@@ -13,6 +13,8 @@ The service provides:
- **๐ Web Management UI**: Browser-based interface for device management
- **๐พ Persistent Data**: Store device configurations, presets, and usage statistics
- **๐ HTTP Recording**: Persist all interactions as re-playable `.http` files
+- **๐ Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
+- **โ๏ธ Parity Logging**: Detect and record discrepancies between local and official Bose responses
- **๐ฅ Session Archiving**: Download entire interaction sessions as `.tar.gz` for offline analysis
- **๐ Auto-Discovery**: Automatically detect and configure SoundTouch devices
- **๐ Offline Operation**: Continue using full device functionality without internet
@@ -167,6 +169,9 @@ The service supports multiple ways to configure its behavior. When multiple sour
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for `resolv.conf` migration) | `:53` |
+| `MIRROR_ENABLED` | | Enable background mirroring of specific endpoints to Bose cloud | `false` |
+| `MIRROR_ENDPOINTS` | | Comma-separated list of path patterns to mirror (e.g., `/streaming/account/*/device/*/recent`) | `[]` |
+| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
### Configuration Examples
@@ -310,6 +315,34 @@ You can enable and configure the DNS server via the Web UI or environment variab
#### Manual Discovery via DNS
Even without migrating a device, you can use the DNS server to discover what a device is querying by manually setting your router's DNS or the device's DNS to point to the AfterTouch service.
+## Endpoint Mirroring & Parity Logging
+
+The SoundTouch service includes a powerful **Mirroring** feature that allows you to handle requests locally while simultaneously forwarding them to the official Bose cloud in the background. This is primarily used for maintaining long-term compatibility and verifying the accuracy of the local emulation.
+
+### How Mirroring Works
+
+When an endpoint is configured for mirroring:
+1. **GET Requests**: Handled locally first (Primary). The response is returned to the speaker immediately. In the background, the same request is sent to Bose.
+2. **POST/PUT/DELETE Requests**: Handled locally first. The service then synchronously (but without blocking the speaker's response) forwards the request to Bose to ensure the "official" account state stays in sync with your local changes (e.g., updating a preset).
+
+### Parity Logging
+
+The **Parity Logger** automatically compares the response from your local service with the one received from Bose. If it detects any discrepancies, it:
+1. Logs a warning to the console: `[PARITY] Mismatch detected for GET /...`
+2. Saves a detailed JSON report to `data/parity_mismatches/`.
+
+Each report includes the full request, both response bodies, and a summary of what differed (status codes, content types, or missing/different XML tags).
+
+### Configuration
+
+Mirroring is configured via the **Settings** tab in the Web UI or through global settings:
+- **Mirror Enabled**: Master switch for the mirroring infrastructure.
+- **Mirror Endpoints**: A list of URL path patterns to mirror. You can use wildcards (`*`) to match variable parts like account or device IDs.
+ - Example: `/streaming/account/*/device/*/recent`
+ - Example: `/accounts/*/devices/*/presets/*`
+
+Mirrored requests are also recorded in the **Interaction Log** under the category `upstream-mirror`, allowing you to see side-by-side exactly how our service's behavior compares to the official one.
+
## API Reference
### Discovery & Setup
@@ -470,6 +503,17 @@ The web management interface provides a comprehensive dashboard for managing you
The service automatically records all HTTP interactions (both those handled locally and those proxied upstream) as `.http` files. These files are compatible with the [IntelliJ IDEA HTTP Client](https://www.jetbrains.com/help/idea/exploring-http-syntax.html).
+### Internal Paths (Excluding Traffic)
+
+To prevent internal management traffic (like the Web UI or setup API calls) from cluttering your interaction logs, you can configure **Internal Paths**. Requests matching these patterns will be processed normally but will **not** be recorded by the `RecordMiddleware`.
+
+By default, we recommend adding:
+- `/setup/*`: Management API calls
+- `/web/*`: Static Web UI resources
+- `/media/*`: Icons and static media
+
+You can configure these via the **Settings** tab in the Web UI or using the `--internal-paths` flag.
+
### Key Features
- **Session Grouping**: All interactions from a single server session are stored in a dedicated directory named `{timestamp}-{pid}`.
diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go
index c330403..9873f11 100644
--- a/pkg/service/datastore/datastore.go
+++ b/pkg/service/datastore/datastore.go
@@ -711,6 +711,9 @@ type Settings struct {
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream []string `json:"dns_upstream,omitempty"`
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
+ MirrorEnabled bool `json:"mirror_enabled"`
+ MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
+ InternalPaths []string `json:"internal_paths,omitempty"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
}
diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go
index 5eae924..e1c9faa 100644
--- a/pkg/service/handlers/handlers_marge.go
+++ b/pkg/service/handlers/handlers_marge.go
@@ -190,7 +190,9 @@ func (s *Server) HandleMargeSoftwareUpdate(w http.ResponseWriter, r *http.Reques
// For the account-specific firmware route, always return the software_update tag.
// This route is specifically used by firmware like Bose_Lisa/27.0.6.
if chi.URLParam(r, "account") != "" {
+ w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
_, _ = w.Write([]byte(marge.SoftwareUpdateToXML()))
+
return
}
diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go
index 3707fc2..7ccae3b 100644
--- a/pkg/service/handlers/handlers_setup.go
+++ b/pkg/service/handlers/handlers_setup.go
@@ -154,6 +154,9 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
dnsEnabled := s.dnsEnabled
dnsUpstream := s.dnsUpstream
dnsBindAddr := s.dnsBindAddr
+ mirrorEnabled := s.mirrorEnabled
+ mirrorEndpoints := s.mirrorEndpoints
+ internalPaths := s.internalPaths
enableSoundcorkProxy := s.enableSoundcorkProxy
redact, logBody, record := s.proxyRedact, s.proxyLogBody, s.recordEnabled
shortcuts := s.shortcuts
@@ -173,6 +176,9 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
"dns_actual_bind": actualBind,
"dns_upstream": strings.Join(dnsUpstream, ","),
"dns_bind_addr": dnsBindAddr,
+ "mirror_enabled": mirrorEnabled,
+ "mirror_endpoints": mirrorEndpoints,
+ "internal_paths": internalPaths,
"enable_soundcork_proxy": enableSoundcorkProxy,
"redact_logs": redact,
"log_bodies": logBody,
@@ -195,6 +201,9 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream string `json:"dns_upstream"`
DNSBindAddr string `json:"dns_bind_addr"`
+ MirrorEnabled bool `json:"mirror_enabled"`
+ MirrorEndpoints []string `json:"mirror_endpoints"`
+ InternalPaths []string `json:"internal_paths"`
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
Shortcuts map[string]int `json:"shortcuts"`
}
@@ -241,6 +250,10 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
s.dnsUpstream = upstreamList
s.dnsBindAddr = settings.DNSBindAddr
+ s.mirrorEnabled = settings.MirrorEnabled
+ s.mirrorEndpoints = settings.MirrorEndpoints
+ s.internalPaths = settings.InternalPaths
+
s.enableSoundcorkProxy = settings.EnableSoundcorkProxy
if settings.Shortcuts != nil {
s.shortcuts = settings.Shortcuts
@@ -270,6 +283,9 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
DNSEnabled: s.dnsEnabled,
DNSUpstream: s.dnsUpstream,
DNSBindAddr: s.dnsBindAddr,
+ MirrorEnabled: s.mirrorEnabled,
+ MirrorEndpoints: s.mirrorEndpoints,
+ InternalPaths: s.internalPaths,
EnableSoundcorkProxy: s.enableSoundcorkProxy,
Shortcuts: s.shortcuts,
})
diff --git a/pkg/service/handlers/handlers_setup_test.go b/pkg/service/handlers/handlers_setup_test.go
index 00abb5e..8f94ba7 100644
--- a/pkg/service/handlers/handlers_setup_test.go
+++ b/pkg/service/handlers/handlers_setup_test.go
@@ -3,6 +3,7 @@ package handlers
import (
"bytes"
"encoding/json"
+ "io"
"net/http"
"net/http/httptest"
"os"
@@ -125,6 +126,52 @@ func TestProxySettingsAPI(t *testing.T) {
if sURL != "http://new-server:8000" || pURL != "http://new-proxy:8001" {
t.Errorf("POST /setup/settings: Server state did not update: serverURL=%s, soundcorkURL=%s", sURL, pURL)
}
+
+ // 4. Test Mirror Settings persistence
+ mirrorUpdate := map[string]interface{}{
+ "server_url": "http://mirror-test:8000",
+ "soundcork_url": "http://mirror-test:8001",
+ "mirror_enabled": true,
+ "mirror_endpoints": []string{"/test/*"},
+ "internal_paths": []string{"/setup/*"},
+ }
+
+ mirrorBody, err := json.Marshal(mirrorUpdate)
+ if err != nil {
+ t.Fatalf("Failed to marshal mirror settings: %v", err)
+ }
+ res, err = http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(mirrorBody))
+ if err != nil {
+ t.Fatal(err)
+ }
+ res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ t.Errorf("POST /setup/settings (mirror): Expected status OK, got %v", res.Status)
+ }
+
+ // Verify server state
+ server.mu.RLock()
+ mEnabled := server.mirrorEnabled
+ mEndpoints := server.mirrorEndpoints
+ iPaths := server.internalPaths
+ server.mu.RUnlock()
+
+ if !mEnabled || len(mEndpoints) != 1 || mEndpoints[0] != "/test/*" {
+ t.Errorf("POST /setup/settings (mirror): Server state did not update: enabled=%v, endpoints=%v", mEnabled, mEndpoints)
+ }
+ if len(iPaths) != 1 || iPaths[0] != "/setup/*" {
+ t.Errorf("POST /setup/settings (mirror): Internal paths did not update: %v", iPaths)
+ }
+
+ // Verify persistence in datastore
+ persisted, _ := ds.GetSettings()
+ if !persisted.MirrorEnabled || len(persisted.MirrorEndpoints) != 1 || persisted.MirrorEndpoints[0] != "/test/*" {
+ t.Errorf("POST /setup/settings (mirror): Datastore did not update: %+v", persisted)
+ }
+ if len(persisted.InternalPaths) != 1 || persisted.InternalPaths[0] != "/setup/*" {
+ t.Errorf("POST /setup/settings (mirror): Datastore internal paths did not update: %+v", persisted)
+ }
}
func TestMigrationAndCA(t *testing.T) {
@@ -145,6 +192,21 @@ func TestMigrationAndCA(t *testing.T) {
return &mockSSH{host: host}
}
+ // Mock HTTPGet to avoid real network timeouts
+ sm.HTTPGet = func(url string) (*http.Response, error) {
+ if strings.HasSuffix(url, "/info") {
+ xml := `Test SpeakerSoundTouch 10default`
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(xml)),
+ }, nil
+ }
+ return &http.Response{
+ StatusCode: http.StatusNotFound,
+ Body: io.NopCloser(strings.NewReader("Not Found")),
+ }, nil
+ }
+
r, server := setupRouter("http://localhost:8001", ds)
server.sm = sm // Inject our manager with mock SSH
diff --git a/pkg/service/handlers/interactions_test.go b/pkg/service/handlers/interactions_test.go
index 992366b..38ed399 100644
--- a/pkg/service/handlers/interactions_test.go
+++ b/pkg/service/handlers/interactions_test.go
@@ -89,6 +89,35 @@ func TestInteractionHandlers(t *testing.T) {
}
})
+ t.Run("HandleListInteractions_Mirror", func(t *testing.T) {
+ // Create a mirror interaction
+ sessionID := recorder.SessionID
+ mirrorRelPath := filepath.Join(sessionID, "mirror", "test", "0002-12-00-01.000-GET.http")
+ fullPath := filepath.Join(tmpDir, "interactions", mirrorRelPath)
+ os.MkdirAll(filepath.Dir(fullPath), 0755)
+ os.WriteFile(fullPath, []byte("### GET /test mirror\n\n> {% \n // Response: 200 OK\n%}\n"), 0644)
+
+ req := httptest.NewRequest("GET", "/setup/interactions?category=mirror", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+
+ var interactions []proxy.Interaction
+ if err := json.NewDecoder(w.Body).Decode(&interactions); err != nil {
+ t.Fatalf("Failed to decode interactions: %v", err)
+ }
+
+ if len(interactions) != 1 {
+ t.Errorf("Expected 1 interaction for mirror, got %d", len(interactions))
+ }
+ if interactions[0].Category != "mirror" {
+ t.Errorf("Expected category mirror, got %s", interactions[0].Category)
+ }
+ })
+
t.Run("HandleGetInteractionContent", func(t *testing.T) {
req := httptest.NewRequest("GET", "/setup/interaction-content?file="+relPath, nil)
w := httptest.NewRecorder()
@@ -140,6 +169,9 @@ func TestRecordMiddleware(t *testing.T) {
f.Flush()
}
})
+ r.Get("/internal/test", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusCreated)
+ })
req := httptest.NewRequest("GET", "/test-middleware", nil)
w := httptest.NewRecorder()
@@ -158,4 +190,22 @@ func TestRecordMiddleware(t *testing.T) {
t.Errorf("Expected status 201, got %d", w.Code)
}
})
+
+ t.Run("HandleRecordMiddleware_InternalPath", func(t *testing.T) {
+ server.recordEnabled = true
+ server.internalPaths = []string{"/internal/*"}
+ req := httptest.NewRequest("GET", "/internal/test", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusCreated {
+ t.Errorf("Expected status 201, got %d", w.Code)
+ }
+
+ // Check if it was recorded (it shouldn't be)
+ matches, _ := filepath.Glob(filepath.Join(tmpDir, "interactions", "*", "self", "internal", "*"))
+ if len(matches) > 0 {
+ t.Errorf("Expected no recording for internal path, found: %v", matches)
+ }
+ })
}
diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go
index 3708e7e..eef4c2b 100644
--- a/pkg/service/handlers/main_test.go
+++ b/pkg/service/handlers/main_test.go
@@ -12,6 +12,7 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r := chi.NewRouter()
r.Use(server.OriginMiddleware)
r.Use(server.ShortcutMiddleware)
+ r.Use(server.MirrorMiddleware)
r.Use(server.RecordMiddleware)
r.Get("/", server.HandleRoot)
diff --git a/pkg/service/handlers/mirror_middleware.go b/pkg/service/handlers/mirror_middleware.go
new file mode 100644
index 0000000..749093c
--- /dev/null
+++ b/pkg/service/handlers/mirror_middleware.go
@@ -0,0 +1,357 @@
+package handlers
+
+import (
+ "bytes"
+ "context"
+ "crypto/tls"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+)
+
+// MirrorMiddleware returns a middleware that mirrors specific requests to the Bose upstream.
+func (s *Server) MirrorMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ s.mu.RLock()
+ enabled := s.mirrorEnabled
+ endpoints := s.mirrorEndpoints
+ s.mu.RUnlock()
+
+ if !enabled || len(endpoints) == 0 {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ shouldMirror := false
+
+ for _, pattern := range endpoints {
+ if matchPattern(pattern, r.URL.Path) {
+ shouldMirror = true
+ break
+ }
+ }
+
+ if !shouldMirror {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // Buffer request body for both local and mirror
+ var bodyBytes []byte
+ if r.Body != nil {
+ bodyBytes, _ = io.ReadAll(r.Body)
+ _ = r.Body.Close()
+ }
+
+ // Prepare local request
+ r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
+
+ // Wrap response writer to capture local response for parity check
+ localRecorder := &mirrorResponseRecorder{
+ headers: make(http.Header),
+ body: &bytes.Buffer{},
+ }
+
+ // Use a multi-writer if RecordMiddleware isn't already doing this,
+ // but let's just wrap it.
+
+ wrappedWriter := &parityResponseWriter{
+ ResponseWriter: w,
+ recorder: localRecorder,
+ }
+
+ if r.Method == http.MethodGet {
+ // GET: Local is primary, Mirror is asynchronous
+ log.Printf("[MIRROR] Mirroring GET %s asynchronously", r.URL.Path)
+
+ // We need a clone for the async call, detached from original request context
+ // We use context.Background() because the original request's context
+ // will be canceled as soon as the local handler finishes and returns
+ // the response to the speaker.
+ //nolint:contextcheck
+ rMirror := r.Clone(context.Background())
+ rMirror.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
+
+ // For GET, we run mirror in background and don't wait for parity in real-time
+ // or we can wait for local to finish then trigger parity asynchronously.
+
+ next.ServeHTTP(wrappedWriter, r)
+
+ go func() {
+ mirrorRes := s.performMirror(rMirror)
+ s.checkParity(r, localRecorder, mirrorRes)
+ }()
+ } else {
+ // POST/PUT/DELETE: Local is primary for speaker response, but we sync synchronously
+ log.Printf("[MIRROR] Mirroring %s %s synchronously", r.Method, r.URL.Path)
+
+ // We need a clone for the background sync call
+ //nolint:contextcheck
+ rMirror := r.Clone(context.Background())
+ rMirror.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
+
+ next.ServeHTTP(wrappedWriter, r)
+
+ go func() {
+ mirrorRes := s.performMirror(rMirror)
+ s.checkParity(r, localRecorder, mirrorRes)
+ }()
+ }
+ })
+}
+
+type parityResponseWriter struct {
+ http.ResponseWriter
+ recorder *mirrorResponseRecorder
+}
+
+func (p *parityResponseWriter) Header() http.Header {
+ return p.ResponseWriter.Header()
+}
+
+func (p *parityResponseWriter) Write(b []byte) (int, error) {
+ p.recorder.body.Write(b)
+ return p.ResponseWriter.Write(b)
+}
+
+func (p *parityResponseWriter) WriteHeader(statusCode int) {
+ p.recorder.status = statusCode
+ p.ResponseWriter.WriteHeader(statusCode)
+}
+
+func (s *Server) performMirror(r *http.Request) *mirrorResponseRecorder {
+ host := r.Host
+ if host == "" || host == "localhost" || strings.HasPrefix(host, "127.0.0.1") {
+ host = "streaming.bose.com"
+ }
+
+ scheme := "https"
+ targetURL := scheme + "://" + host
+
+ target, err := url.Parse(targetURL)
+ if err != nil {
+ log.Printf("[MIRROR_ERR] Failed to parse target URL %s: %v", targetURL, err)
+ return nil
+ }
+
+ // Create a proxy that doesn't write to the original ResponseWriter
+ proxy := httputil.NewSingleHostReverseProxy(target)
+ proxy.Transport = &http.Transport{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+ }
+
+ // Record the mirrored request
+ originalDirector := proxy.Director
+ proxy.Director = func(req *http.Request) {
+ originalDirector(req)
+ req.Host = target.Host
+ req.Header.Set("X-Mirror-Request", "true")
+ }
+
+ // Capture response for parity check and recording
+ recorder := &mirrorResponseRecorder{
+ headers: make(http.Header),
+ body: &bytes.Buffer{},
+ }
+
+ proxy.ModifyResponse = func(res *http.Response) error {
+ res.Header.Set("X-Proxy-Origin", "upstream-mirror")
+
+ // Record mirrored interaction
+ if s.recorder != nil && s.recordEnabled {
+ _ = s.recorder.Record("mirror", r, res)
+ }
+
+ return nil
+ }
+
+ // We use a dummy ResponseWriter to capture the results
+ proxy.ServeHTTP(recorder, r)
+
+ log.Printf("[MIRROR] Mirror completed for %s with status %d", r.URL.Path, recorder.status)
+
+ return recorder
+}
+
+func (s *Server) checkParity(req *http.Request, local, upstream *mirrorResponseRecorder) {
+ if local.status == 0 {
+ local.status = 200
+ }
+
+ if upstream.status == 0 {
+ upstream.status = 200
+ }
+
+ mismatch := false
+ reasons := []string{}
+
+ if local.status != upstream.status {
+ mismatch = true
+
+ reasons = append(reasons, fmt.Sprintf("Status mismatch: local %d, upstream %d", local.status, upstream.status))
+ }
+
+ // Compare Content-Type
+ localCT := local.headers.Get("Content-Type")
+
+ upstreamCT := upstream.headers.Get("Content-Type")
+ if localCT != upstreamCT {
+ mismatch = true
+
+ reasons = append(reasons, fmt.Sprintf("Content-Type mismatch: local %s, upstream %s", localCT, upstreamCT))
+ }
+
+ // Basic body comparison (could be improved with XML semantic diff)
+ if !bytes.Equal(local.body.Bytes(), upstream.body.Bytes()) {
+ mismatch = true
+
+ reasons = append(reasons, "Body content mismatch")
+ }
+
+ if mismatch {
+ log.Printf("[PARITY] Mismatch detected for %s %s: %v", req.Method, req.URL.Path, reasons)
+ s.saveParityMismatch(req, local, upstream, reasons)
+ }
+}
+
+func (s *Server) saveParityMismatch(req *http.Request, local, upstream *mirrorResponseRecorder, reasons []string) {
+ record := map[string]interface{}{
+ "timestamp": time.Now().Format(time.RFC3339),
+ "method": req.Method,
+ "path": req.URL.Path,
+ "reasons": reasons,
+ "local": map[string]interface{}{
+ "status": local.status,
+ "body": local.body.String(),
+ },
+ "upstream": map[string]interface{}{
+ "status": upstream.status,
+ "body": upstream.body.String(),
+ },
+ }
+
+ data, err := json.MarshalIndent(record, "", " ")
+ if err != nil {
+ log.Printf("[PARITY_ERR] Failed to marshal parity record: %v", err)
+ return
+ }
+
+ dir := filepath.Join(s.ds.DataDir, "parity_mismatches")
+ _ = os.MkdirAll(dir, 0755)
+
+ filename := fmt.Sprintf("%d_%s.json", time.Now().Unix(), strings.ReplaceAll(req.URL.Path, "/", "_"))
+ _ = os.WriteFile(filepath.Join(dir, filename), data, 0644)
+}
+
+type mirrorResponseRecorder struct {
+ status int
+ headers http.Header
+ body *bytes.Buffer
+}
+
+func (m *mirrorResponseRecorder) Header() http.Header {
+ return m.headers
+}
+
+func (m *mirrorResponseRecorder) Write(b []byte) (int, error) {
+ return m.body.Write(b)
+}
+
+func (m *mirrorResponseRecorder) WriteHeader(statusCode int) {
+ m.status = statusCode
+}
+
+// matchPattern checks if a path matches a pattern with wildcards (*)
+func matchPattern(pattern, name string) bool {
+ matched, _ := path.Match(pattern, name)
+ if matched {
+ return true
+ }
+ // Also try prefix match if pattern ends with /*
+ if strings.HasSuffix(pattern, "/*") {
+ prefix := strings.TrimSuffix(pattern, "/*")
+ if strings.HasPrefix(name, prefix) {
+ return true
+ }
+ }
+
+ return false
+}
+
+// HandleListParityMismatches returns a list of parity mismatches.
+func (s *Server) HandleListParityMismatches(w http.ResponseWriter, _ *http.Request) {
+ dir := filepath.Join(s.ds.DataDir, "parity_mismatches")
+ if _, err := os.Stat(dir); os.IsNotExist(err) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte("[]"))
+
+ return
+ }
+
+ files, err := os.ReadDir(dir)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ var mismatches []interface{}
+
+ for _, file := range files {
+ if !file.IsDir() && strings.HasSuffix(file.Name(), ".json") {
+ data, err := os.ReadFile(filepath.Join(dir, file.Name()))
+ if err == nil {
+ var record interface{}
+ if json.Unmarshal(data, &record) == nil {
+ // Add filename as ID for downloading/deletion if needed
+ if m, ok := record.(map[string]interface{}); ok {
+ m["id"] = file.Name()
+ mismatches = append(mismatches, m)
+ } else {
+ mismatches = append(mismatches, record)
+ }
+ }
+ }
+ }
+ }
+
+ // Sort by timestamp descending if possible
+ sort.Slice(mismatches, func(i, j int) bool {
+ mi, oki := mismatches[i].(map[string]interface{})
+
+ mj, okj := mismatches[j].(map[string]interface{})
+ if oki && okj {
+ ti, _ := mi["timestamp"].(string)
+ tj, _ := mj["timestamp"].(string)
+
+ return ti > tj
+ }
+
+ return false
+ })
+
+ w.Header().Set("Content-Type", "application/json")
+
+ if err := json.NewEncoder(w).Encode(mismatches); err != nil {
+ log.Printf("[PARITY_ERR] Failed to encode mismatches: %v", err)
+ }
+}
+
+// HandleClearParityMismatches deletes all parity mismatch records.
+func (s *Server) HandleClearParityMismatches(w http.ResponseWriter, _ *http.Request) {
+ dir := filepath.Join(s.ds.DataDir, "parity_mismatches")
+ _ = os.RemoveAll(dir)
+ _ = os.MkdirAll(dir, 0755)
+
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte("{\"ok\": true}"))
+}
diff --git a/pkg/service/handlers/mirror_test.go b/pkg/service/handlers/mirror_test.go
new file mode 100644
index 0000000..8ce5149
--- /dev/null
+++ b/pkg/service/handlers/mirror_test.go
@@ -0,0 +1,101 @@
+package handlers
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gesellix/bose-soundtouch/pkg/service/datastore"
+ "github.com/gesellix/bose-soundtouch/pkg/service/proxy"
+)
+
+func TestMirroring(t *testing.T) {
+ tempDir, err := os.MkdirTemp("", "st-mirror-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tempDir)
+
+ ds := datastore.NewDataStore(tempDir)
+
+ // Create a mock Bose Upstream
+ boseUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Only handle requests to the actual path
+ if strings.HasSuffix(r.URL.Path, "/recent") {
+ w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(""))
+ return
+ }
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer boseUpstream.Close()
+
+ // Setup local server
+ r, server := setupRouter("http://localhost:8001", ds)
+
+ // Setup recorder
+ recorder := proxy.NewRecorder(tempDir)
+ server.SetRecorder(recorder)
+ server.SetRecordEnabled(true)
+ server.SetMirrorSettings(true, []string{"/streaming/account/*/device/*/recent"})
+
+ ts := httptest.NewServer(r)
+ defer ts.Close()
+
+ account := "123"
+ deviceID := "DEV1"
+
+ // Ensure the datastore has the necessary directories for the local handler
+ deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
+ _ = os.MkdirAll(deviceDir, 0755)
+ _ = os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(""), 0644)
+ _ = os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(""), 0644)
+
+ t.Run("Mirrored Endpoint", func(t *testing.T) {
+ req, _ := http.NewRequest("GET", ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", nil)
+ // We set the host to our mock upstream so performMirror finds it
+ req.Host = strings.TrimPrefix(boseUpstream.URL, "http://")
+
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ t.Errorf("Expected status OK, got %v", res.Status)
+ }
+
+ // Wait a bit for the async mirror to complete and be recorded
+ time.Sleep(500 * time.Millisecond)
+
+ // Check if the interaction was recorded twice
+ // Category: self
+ matchesSelf, _ := filepath.Glob(filepath.Join(tempDir, "interactions", "*", "self", "*", "*"))
+ if len(matchesSelf) == 0 {
+ // List directory for debugging
+ files, _ := filepath.Glob(filepath.Join(tempDir, "interactions", "*", "*", "*", "*"))
+ t.Errorf("Expected to find local interaction in logs (category: self). Found: %v", files)
+ }
+
+ // Category: mirror
+ matchesMirror, _ := filepath.Glob(filepath.Join(tempDir, "interactions", "*", "mirror", "*", "*"))
+ if len(matchesMirror) == 0 {
+ // List directory for debugging
+ files, _ := filepath.Glob(filepath.Join(tempDir, "interactions", "*", "*", "*", "*"))
+ t.Errorf("Expected to find mirrored interaction in logs (category: mirror). Found: %v", files)
+ }
+ })
+}
+
+// SetRecordEnabled is a helper for testing
+func (s *Server) SetRecordEnabled(enabled bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.recordEnabled = enabled
+}
diff --git a/pkg/service/handlers/recorder_middleware.go b/pkg/service/handlers/recorder_middleware.go
index 10101f8..7706238 100644
--- a/pkg/service/handlers/recorder_middleware.go
+++ b/pkg/service/handlers/recorder_middleware.go
@@ -17,6 +17,17 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
return
}
+ s.mu.RLock()
+ internalPaths := s.internalPaths
+ s.mu.RUnlock()
+
+ for _, pattern := range internalPaths {
+ if matchPattern(pattern, r.URL.Path) {
+ next.ServeHTTP(w, r)
+ return
+ }
+ }
+
// Buffer the request body if it exists
var reqBody []byte
diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go
index 62b5b57..570955e 100644
--- a/pkg/service/handlers/server.go
+++ b/pkg/service/handlers/server.go
@@ -38,6 +38,9 @@ type Server struct {
dnsEnabled bool
dnsUpstream []string
dnsBindAddr string
+ mirrorEnabled bool
+ mirrorEndpoints []string
+ internalPaths []string
enableSoundcorkProxy bool
shortcuts map[string]int
recorder *proxy.Recorder
@@ -303,6 +306,23 @@ func (s *Server) SetMgmtConfig(username, password string) {
s.mgmtPassword = password
}
+// SetMirrorSettings sets the mirroring settings for the server.
+func (s *Server) SetMirrorSettings(enabled bool, endpoints []string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.mirrorEnabled = enabled
+ s.mirrorEndpoints = endpoints
+}
+
+// SetInternalPaths sets the internal paths for the server.
+func (s *Server) SetInternalPaths(paths []string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.internalPaths = paths
+}
+
// SetSpotifyService sets the Spotify OAuth service.
func (s *Server) SetSpotifyService(ss *spotify.Service) {
s.mu.Lock()
diff --git a/pkg/service/handlers/web/css/style.css b/pkg/service/handlers/web/css/style.css
index 8c321aa..c444c3f 100644
--- a/pkg/service/handlers/web/css/style.css
+++ b/pkg/service/handlers/web/css/style.css
@@ -100,6 +100,7 @@ pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px;
}
.category-self { background-color: #e3f2fd; color: #0d47a1; }
.category-upstream { background-color: #f3e5f5; color: #7b1fa2; }
+.category-mirror { background-color: #fff3e0; color: #e65100; }
.status-success { background-color: #e8f5e9; color: #2e7d32; }
.status-error { background-color: #ffebee; color: #c62828; }
diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html
index 4265bd6..983559c 100644
--- a/pkg/service/handlers/web/index.html
+++ b/pkg/service/handlers/web/index.html
@@ -18,6 +18,7 @@
+
@@ -96,10 +97,6 @@
-
-
-
-
DNS Discovery:
@@ -123,6 +120,23 @@
+
+ Endpoint Mirroring:
+
+
+
+
+
+
+ Note: Mirroring sends matching requests (including full headers) to the official Bose servers for parity comparison.
+ If Redact Sensitive Data is enabled in Proxy Settings, credentials will be masked in logs and recordings, but
+ full headers are always sent to Bose to ensure service compatibility.
+
+
+
+
Spotify Integration:
@@ -139,8 +153,20 @@
Record Interactions
(View in 5. Interactions tab)
+
+
+
+
+ Requests matching these patterns will be excluded from recording. Use one pattern per line.
+
+
+
+
+
+
+
@@ -392,6 +418,7 @@
+
@@ -484,6 +511,64 @@
+
+
+
+
Parity Analysis
+
Detection of discrepancies between AfterTouch local responses and official Bose Cloud responses for mirrored endpoints.