mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
feat(mirror): add background mirroring and parity analysis for Bose services
Implements the ability to mirror local requests to the official Bose Cloud in the background, allowing for real-time comparison and parity analysis between the emulated service and the original backend. Core Changes: - Implement `MirrorMiddleware` for asynchronous and synchronous mirroring. - Add `Parity Logger` to detect discrepancies in status, headers, and body. - Implement storage for parity mismatches in `data/parity_mismatches/`. - Add `Internal Paths` configuration to exclude management traffic from logs. Web UI & API: - Add "Parity & Mirroring" tab to the Web UI for discrepancy analysis. - Integrated "Internal Paths" configuration in Settings. - Add "mirror" category filter to the Interactions UI. - Implement endpoints for listing and clearing parity mismatches. Infrastructure & Tools: - Extend `setup.Manager` with `HTTPGet` override for reliable testing. - Add CLI flags `--mirror-enabled`, `--mirror-endpoints`, and `--internal-paths`. - Update `datastore.Settings` to persist mirroring and internal path configurations. Tests: - Add `pkg/service/handlers/mirror_test.go` for middleware verification. - Update `TestProxySettingsAPI` and `TestRecordMiddleware` for new settings. - Refactor `TestMigrationAndCA` to use mocked network calls (30x speedup).
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`.
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 := `<?xml version="1.0" encoding="UTF-8" ?><info deviceID="192.168.1.10"><name>Test Speaker</name><type>SoundTouch 10</type><margeAccountUUID>default</margeAccountUUID></info>`
|
||||
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
@@ -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("<bose-response/>"))
|
||||
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("<recents/>"), 0644)
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte("<sources/>"), 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<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 & Events</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-parity')">6. Parity & Mirroring</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 0: Overview -->
|
||||
@@ -96,10 +97,6 @@
|
||||
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px;">
|
||||
<label style="margin-left: 15px;"><input type="checkbox" id="discovery-enabled"> Enable Automated Discovery</label>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<button onclick="updateSettings()">Save Settings</button>
|
||||
<span id="settings-status" style="margin-left: 10px; font-size: 0.9em;"></span>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<strong>DNS Discovery:</strong>
|
||||
<div style="margin-top: 5px;">
|
||||
@@ -123,6 +120,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<strong>Endpoint Mirroring:</strong>
|
||||
<div style="margin-top: 5px;">
|
||||
<label style="display: block; margin-bottom: 5px;">
|
||||
<input type="checkbox" id="mirror-enabled"> Enable Background Mirroring to Bose Cloud
|
||||
</label>
|
||||
<div style="margin-left: 20px; margin-bottom: 5px;">
|
||||
<label for="mirror-endpoints">Mirror Endpoints (one per line, supports * wildcards):</label><br>
|
||||
<textarea id="mirror-endpoints" rows="4" style="width: 100%; max-width: 600px; margin-top: 5px; font-family: monospace;" placeholder="/streaming/account/*/device/*/recent /accounts/*/devices/*/presets/*"></textarea>
|
||||
<div class="info-box" style="margin-top: 5px; font-size: 0.85em; padding: 10px;">
|
||||
<strong>Note:</strong> Mirroring sends matching requests (including full headers) to the official Bose servers for parity comparison.
|
||||
If <em>Redact Sensitive Data</em> is enabled in Proxy Settings, credentials will be masked in <strong>logs and recordings</strong>, but
|
||||
full headers are always sent to Bose to ensure service compatibility.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<strong>Spotify Integration:</strong>
|
||||
<div id="spotify-config-status" style="margin-top: 5px; font-size: 0.9em;">
|
||||
@@ -139,8 +153,20 @@
|
||||
<input type="checkbox" id="proxy-record" onchange="updateProxySettings()"> Record Interactions
|
||||
<span style="font-size: 0.85em; color: #666; margin-left: 5px;">(View in <strong>5. Interactions</strong> tab)</span>
|
||||
</label>
|
||||
<div style="margin-left: 20px; margin-top: 10px;">
|
||||
<label for="internal-paths">Internal Paths (skip recording for these patterns):</label><br>
|
||||
<textarea id="internal-paths" rows="2" style="width: 100%; max-width: 600px; margin-top: 5px; font-family: monospace;" placeholder="/setup/* /web/*"></textarea>
|
||||
<div style="font-size: 0.8em; color: #666; margin-top: 2px;">
|
||||
Requests matching these patterns will be excluded from recording. Use one pattern per line.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<button onclick="updateSettings()">Save Settings</button>
|
||||
<span id="settings-status" style="margin-left: 10px; font-size: 0.9em;"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Devices -->
|
||||
@@ -392,6 +418,7 @@
|
||||
<option value="">All Categories</option>
|
||||
<option value="self">Self (Emulated)</option>
|
||||
<option value="upstream">Upstream (Bose)</option>
|
||||
<option value="mirror">Mirror (Bose)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -484,6 +511,64 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 6: Parity & Mirroring -->
|
||||
<div id="tab-parity" class="tab-content">
|
||||
<h2>Parity Analysis</h2>
|
||||
<p>Detection of discrepancies between AfterTouch local responses and official Bose Cloud responses for mirrored endpoints.</p>
|
||||
|
||||
<div class="summary-box">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">Parity Mismatches</h3>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button onclick="fetchParityMismatches()">Refresh Mismatches</button>
|
||||
<button onclick="clearParityMismatches()" class="btn-danger">Clear All Records</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="parity-list-container" style="max-height: 500px; 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;">Method</th>
|
||||
<th style="padding: 8px;">Path</th>
|
||||
<th style="padding: 8px;">Reasons</th>
|
||||
<th style="padding: 8px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="parity-mismatches-list">
|
||||
<tr><td colspan="5" style="padding: 20px; text-align: center; color: #666;">Loading mismatches...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="parity-diff-view" class="summary-box" style="display: none; margin-top: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">Mismatch Detail: <span id="diff-path-display"></span></h3>
|
||||
<button onclick="document.getElementById('parity-diff-view').style.display='none'">Close Detail</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 15px; padding: 10px; background: #fff4f4; border: 1px solid #f5c6cb; border-radius: 4px; color: #721c24;">
|
||||
<strong>Detection Reasons:</strong>
|
||||
<ul id="diff-reasons-list" style="margin: 5px 0 0 0; padding-left: 20px;"></ul>
|
||||
</div>
|
||||
|
||||
<div class="diff-container" style="margin-top: 15px;">
|
||||
<div class="diff-pane">
|
||||
<span class="config-header" style="background: #eefbff; color: #0056b3;">Local Response (AfterTouch)</span>
|
||||
<div id="diff-local-meta" style="font-size: 0.8em; margin-bottom: 5px; color: #666;"></div>
|
||||
<pre id="diff-local-body" style="background: #f8f9fa; border: 1px solid #eee; padding: 10px; font-size: 0.85em; overflow-x: auto;"></pre>
|
||||
</div>
|
||||
<div class="diff-pane">
|
||||
<span class="config-header" style="background: #fff4e6; color: #856404;">Upstream Response (Bose)</span>
|
||||
<div id="diff-upstream-meta" style="font-size: 0.8em; margin-bottom: 5px; color: #666;"></div>
|
||||
<pre id="diff-upstream-body" style="background: #f8f9fa; border: 1px solid #eee; padding: 10px; font-size: 0.85em; overflow-x: auto;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/web/js/script.js"></script>
|
||||
|
||||
@@ -152,6 +152,16 @@ async function fetchSettings() {
|
||||
dnsCurrentUpstream.innerText = '';
|
||||
}
|
||||
|
||||
if (settings.mirror_enabled !== undefined) {
|
||||
document.getElementById('mirror-enabled').checked = settings.mirror_enabled;
|
||||
}
|
||||
if (settings.mirror_endpoints) {
|
||||
document.getElementById('mirror-endpoints').value = settings.mirror_endpoints.join('\n');
|
||||
}
|
||||
if (settings.internal_paths) {
|
||||
document.getElementById('internal-paths').value = settings.internal_paths.join('\n');
|
||||
}
|
||||
|
||||
if (settings.enable_soundcork_proxy !== undefined) {
|
||||
document.getElementById('enable-soundcork-proxy').checked = settings.enable_soundcork_proxy;
|
||||
}
|
||||
@@ -215,6 +225,9 @@ async function updateSettings() {
|
||||
dns_enabled: document.getElementById('dns-enabled').checked,
|
||||
dns_upstream: document.getElementById('dns-upstream').value,
|
||||
dns_bind_addr: document.getElementById('dns-bind').value,
|
||||
mirror_enabled: document.getElementById('mirror-enabled').checked,
|
||||
mirror_endpoints: document.getElementById('mirror-endpoints').value.split('\n').map(s => s.trim()).filter(s => s !== ''),
|
||||
internal_paths: document.getElementById('internal-paths').value.split('\n').map(s => s.trim()).filter(s => s !== ''),
|
||||
enable_soundcork_proxy: document.getElementById('enable-soundcork-proxy').checked
|
||||
};
|
||||
const status = document.getElementById('settings-status');
|
||||
@@ -349,6 +362,10 @@ function openTab(evt, tabId) {
|
||||
fetchDNSDiscoveries();
|
||||
}
|
||||
|
||||
if (tabId === 'tab-parity') {
|
||||
fetchParityMismatches();
|
||||
}
|
||||
|
||||
if (evt) {
|
||||
evt.currentTarget.className += " active";
|
||||
} else {
|
||||
@@ -823,11 +840,110 @@ async function fetchDeviceEvents(deviceId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchParityMismatches() {
|
||||
const list = document.getElementById('parity-mismatches-list');
|
||||
list.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #666;">Loading mismatches...</td></tr>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/parity-mismatches');
|
||||
const mismatches = await response.json();
|
||||
|
||||
list.innerHTML = '';
|
||||
if (!mismatches || mismatches.length === 0) {
|
||||
list.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #666;">No parity mismatches detected yet.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
mismatches.forEach(m => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.style.borderBottom = '1px solid #eee';
|
||||
|
||||
const time = m.timestamp || "";
|
||||
const method = m.method || "";
|
||||
const path = m.path || "";
|
||||
const reasons = (m.reasons || []).join(', ');
|
||||
|
||||
tr.innerHTML = `
|
||||
<td style="padding: 8px; font-size: 0.8em;">${time}</td>
|
||||
<td style="padding: 8px; font-family: monospace;">${method}</td>
|
||||
<td style="padding: 8px; font-size: 0.9em;">${path}</td>
|
||||
<td style="padding: 8px; font-size: 0.85em; color: #c62828;">${reasons}</td>
|
||||
<td style="padding: 8px;"><button onclick='viewParityMismatch(${JSON.stringify(m)})'>View Diff</button></td>
|
||||
`;
|
||||
list.appendChild(tr);
|
||||
});
|
||||
} catch (error) {
|
||||
list.innerHTML = `<tr><td colspan="5" style="padding: 20px; text-align: center; color: #f44336;">Error loading mismatches: ${error.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearParityMismatches() {
|
||||
if (!confirm('Are you sure you want to clear all parity mismatch records?')) return;
|
||||
try {
|
||||
await fetch('/setup/parity-mismatches', { method: 'DELETE' });
|
||||
fetchParityMismatches();
|
||||
document.getElementById('parity-diff-view').style.display = 'none';
|
||||
} catch (error) {
|
||||
alert('Failed to clear mismatches: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function viewParityMismatch(m) {
|
||||
document.getElementById('diff-path-display').innerText = m.method + ' ' + m.path;
|
||||
const reasonsList = document.getElementById('diff-reasons-list');
|
||||
reasonsList.innerHTML = '';
|
||||
(m.reasons || []).forEach(r => {
|
||||
const li = document.createElement('li');
|
||||
li.innerText = r;
|
||||
reasonsList.appendChild(li);
|
||||
});
|
||||
|
||||
document.getElementById('diff-local-meta').innerText = `Status: ${m.local.status}`;
|
||||
document.getElementById('diff-upstream-meta').innerText = `Status: ${m.upstream.status}`;
|
||||
|
||||
document.getElementById('diff-local-body').innerText = formatXML(m.local.body);
|
||||
document.getElementById('diff-upstream-body').innerText = formatXML(m.upstream.body);
|
||||
|
||||
document.getElementById('parity-diff-view').style.display = 'block';
|
||||
document.getElementById('parity-diff-view').scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function formatXML(xml) {
|
||||
if (!xml) return '';
|
||||
try {
|
||||
let formatted = '';
|
||||
let reg = /(>)(<)(\/*)/g;
|
||||
xml = xml.replace(reg, '$1\r\n$2$3');
|
||||
let pad = 0;
|
||||
xml.split('\r\n').forEach(function(node) {
|
||||
let indent = 0;
|
||||
if (node.match(/.+<\/\w[^>]*>$/)) {
|
||||
indent = 0;
|
||||
} else if (node.match(/^<\/\w/)) {
|
||||
if (pad !== 0) pad -= 1;
|
||||
} else if (node.match(/^<\w[^>]*[^\/]>.*$/)) {
|
||||
indent = 1;
|
||||
} else {
|
||||
indent = 0;
|
||||
}
|
||||
|
||||
let padding = '';
|
||||
for (let i = 0; i < pad; i++) padding += ' ';
|
||||
formatted += padding + node + '\r\n';
|
||||
pad += indent;
|
||||
});
|
||||
return formatted.trim();
|
||||
} catch (e) {
|
||||
return xml;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchSettings();
|
||||
fetchDevices();
|
||||
triggerDiscovery();
|
||||
fetchVersion();
|
||||
fetchParityMismatches();
|
||||
|
||||
const syncBtn = document.getElementById('sync-now-btn');
|
||||
if (syncBtn) syncBtn.onclick = startSync;
|
||||
|
||||
@@ -69,6 +69,8 @@ type MigrationSummary struct {
|
||||
CurrentResolvConf string `json:"current_resolv_conf,omitempty"`
|
||||
PlannedResolv string `json:"planned_resolv,omitempty"`
|
||||
IsMigrated bool `json:"is_migrated"`
|
||||
MirrorEnabled bool `json:"mirror_enabled"`
|
||||
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
|
||||
}
|
||||
|
||||
// SSHClient defines the interface for SSH operations.
|
||||
@@ -87,6 +89,9 @@ type Manager struct {
|
||||
// GetDNSRunning is an optional callback to check the actual state of the DNS server.
|
||||
GetDNSRunning func() (bool, string)
|
||||
|
||||
// HTTPGet is an optional override for http.Get (primarily for testing).
|
||||
HTTPGet func(url string) (*http.Response, error)
|
||||
|
||||
// Spotify management credentials for the boot primer
|
||||
MgmtUsername string
|
||||
MgmtPassword string
|
||||
@@ -101,6 +106,7 @@ func NewManager(serverURL string, ds *datastore.DataStore, cm *certmanager.Certi
|
||||
NewSSH: func(host string) SSHClient {
|
||||
return ssh.NewClient(host)
|
||||
},
|
||||
HTTPGet: http.Get,
|
||||
MgmtUsername: "admin",
|
||||
MgmtPassword: "change_me!",
|
||||
}
|
||||
@@ -132,7 +138,7 @@ func (m *Manager) GetLiveDeviceInfo(deviceIP string) (*DeviceInfoXML, error) {
|
||||
_ = host
|
||||
}
|
||||
|
||||
resp, err := http.Get(infoURL)
|
||||
resp, err := m.HTTPGet(infoURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch info from %s: %w", infoURL, err)
|
||||
}
|
||||
@@ -278,6 +284,15 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
|
||||
// 6. Check if migrated
|
||||
m.checkIsMigrated(summary, deviceIP)
|
||||
|
||||
// 7. Mirroring settings
|
||||
if m.DataStore != nil {
|
||||
settings, err := m.DataStore.GetSettings()
|
||||
if err == nil {
|
||||
summary.MirrorEnabled = settings.MirrorEnabled
|
||||
summary.MirrorEndpoints = settings.MirrorEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
@@ -2064,7 +2079,7 @@ func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
|
||||
presetsURL = fmt.Sprintf("http://%s/presets", deviceIP)
|
||||
}
|
||||
|
||||
resp, err := http.Get(presetsURL)
|
||||
resp, err := m.HTTPGet(presetsURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -2119,7 +2134,7 @@ func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
|
||||
recentsURL = fmt.Sprintf("http://%s/recents", deviceIP)
|
||||
}
|
||||
|
||||
resp, err := http.Get(recentsURL)
|
||||
resp, err := m.HTTPGet(recentsURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -2186,7 +2201,7 @@ func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
|
||||
sourcesURL = fmt.Sprintf("http://%s/sources", deviceIP)
|
||||
}
|
||||
|
||||
resp, err := http.Get(sourcesURL)
|
||||
resp, err := m.HTTPGet(sourcesURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -280,6 +280,45 @@ func TestGetMigrationSummary_WithProxyOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMigrationSummary_MirrorSettings(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-test-mirror-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
settings := datastore.Settings{
|
||||
MirrorEnabled: true,
|
||||
MirrorEndpoints: []string{"/recent", "/presets"},
|
||||
}
|
||||
if err := ds.SaveSettings(settings); err != nil {
|
||||
t.Fatalf("Failed to save settings: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager("http://localhost:8000", ds, nil)
|
||||
|
||||
// Mock server for live info
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = fmt.Fprint(w, `<info deviceID="123"><name>Test</name></info>`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
summary, err := m.GetMigrationSummary(server.Listener.Addr().String(), "", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMigrationSummary failed: %v", err)
|
||||
}
|
||||
|
||||
if !summary.MirrorEnabled {
|
||||
t.Error("Expected MirrorEnabled to be true in summary")
|
||||
}
|
||||
|
||||
if len(summary.MirrorEndpoints) != 2 || summary.MirrorEndpoints[0] != "/recent" {
|
||||
t.Errorf("Expected MirrorEndpoints [/recent /presets], got %v", summary.MirrorEndpoints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCACertTrusted(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "ca-trust-test")
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user