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:
Tobias Gesellchen
2026-02-22 22:20:03 +01:00
parent b71a3830ec
commit 9ee1c96477
18 changed files with 972 additions and 8 deletions
+4
View File
@@ -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
+37
View File
@@ -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)
+44
View File
@@ -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}`.
+3
View File
@@ -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"`
}
+2
View File
@@ -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
}
+16
View File
@@ -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
+50
View File
@@ -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)
}
})
}
+1
View File
@@ -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)
+357
View File
@@ -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}"))
}
+101
View File
@@ -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
+20
View File
@@ -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()
+1
View File
@@ -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; }
+89 -4
View File
@@ -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&#10;/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/*&#10;/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>
+116
View File
@@ -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;
+19 -4
View File
@@ -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
}
+39
View File
@@ -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 {