From 7d76b3fab2350691dfe134c79ad7f118b6cbdf52 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 15 Feb 2026 22:42:35 +0100 Subject: [PATCH] Implement dynamic Bose proxy with detailed origin logging and Soundcork fallback --- cmd/soundtouch-service/main.go | 98 ++++--------- pkg/service/handlers/handlers_proxy.go | 170 +++++++++++++++++++--- pkg/service/handlers/main_test.go | 51 ++++--- pkg/service/handlers/origin_middleware.go | 27 ++++ pkg/service/handlers/server.go | 12 +- pkg/service/setup/setup.go | 1 + 6 files changed, 250 insertions(+), 109 deletions(-) create mode 100644 pkg/service/handlers/origin_middleware.go diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 318fd73..5e14b43 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -9,7 +9,6 @@ import ( "fmt" "log" "net/http" - "net/http/httputil" "net/url" "os" "path/filepath" @@ -161,6 +160,7 @@ func main() { cm := initCertificateManager(config.dataDir) sm := setup.NewManager(config.serverURL, ds, cm) server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.enableSoundcorkProxy) + server.SetSoundcorkURL(config.soundcorkURL) server.SetHTTPServerURL(config.httpsServerURL) server.SetVersionInfo(version, commit, date) server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled) @@ -203,11 +203,9 @@ func main() { log.Printf("Warning: Failed to setup TLS: %v", err) } - scProxy := setupSoundcorkProxy(config.soundcorkURL, config.redact, config.logBody, recorder, server) - startDeviceDiscovery(server) - r := setupRouter(server, scProxy, config.enableSoundcorkProxy) + r := setupRouter(server) log.Printf("Go service starting on %s, proxying to %s", config.serverURL, config.soundcorkURL) @@ -429,64 +427,6 @@ func initCertificateManager(dataDir string) *certmanager.CertificateManager { return cm } -func setupSoundcorkProxy(soundcorkURL string, redact, logBody bool, recorder *proxy.Recorder, server *handlers.Server) *httputil.ReverseProxy { - target, err := url.Parse(soundcorkURL) - if err != nil { - log.Fatalf("Failed to parse Soundcork URL: %v", err) - } - - scProxy := httputil.NewSingleHostReverseProxy(target) - scProxy.ModifyResponse = func(res *http.Response) error { - if etags, ok := res.Header["Etag"]; ok { - delete(res.Header, "Etag") - res.Header["ETag"] = etags - } - - currentLp := proxy.NewLoggingProxy(target.String(), redact) - currentLp.LogBody = logBody - currentLp.RecordEnabled = server.GetRecordEnabled() - currentLp.SetRecorder(recorder) - currentLp.LogResponse(res) - - return nil - } - - originalScDirector := scProxy.Director - scProxy.Director = func(req *http.Request) { - originalScDirector(req) - - // Fix X-Forwarded-For bloat by deduplicating - if xff := req.Header.Get("X-Forwarded-For"); xff != "" { - parts := strings.Split(xff, ",") - seen := make(map[string]bool) - unique := make([]string, 0, len(parts)) - - for _, p := range parts { - p = strings.TrimSpace(p) - if p != "" && !seen[p] { - seen[p] = true - unique = append(unique, p) - } - } - - // Limit the number of entries to prevent header overflow - if len(unique) > 10 { - unique = unique[len(unique)-10:] - } - - req.Header.Set("X-Forwarded-For", strings.Join(unique, ", ")) - } - - currentLp := proxy.NewLoggingProxy(target.String(), redact) - currentLp.LogBody = logBody - currentLp.RecordEnabled = server.GetRecordEnabled() - currentLp.SetRecorder(recorder) - currentLp.LogRequest(req) - } - - return scProxy -} - func startDeviceDiscovery(server *handlers.Server) { go func() { for { @@ -500,9 +440,9 @@ func startDeviceDiscovery(server *handlers.Server) { }() } -func setupRouter(server *handlers.Server, scProxy *httputil.ReverseProxy, enableSoundcorkProxy bool) *chi.Mux { +func setupRouter(server *handlers.Server) *chi.Mux { r := chi.NewRouter() - r.Use(middleware.Logger) + r.Use(server.OriginMiddleware) r.Use(middleware.Recoverer) r.Use(server.ShortcutMiddleware) r.Use(server.RecordMiddleware) @@ -526,6 +466,13 @@ func setupRouter(server *handlers.Server, scProxy *httputil.ReverseProxy, enable r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback) }) + // Legacy or direct domain calls without /bmx prefix + r.Get("/registry/v1/services", server.HandleBMXRegistry) + r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback) + r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo) + r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast) + r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback) + r.Route("/marge", func(r chi.Router) { r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders) r.Get("/accounts/{account}/full", server.HandleMargeAccountFull) @@ -544,6 +491,23 @@ func setupRouter(server *handlers.Server, scProxy *httputil.ReverseProxy, enable r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress) }) + // Legacy or direct domain calls without /marge prefix + r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders) + r.Get("/accounts/{account}/full", server.HandleMargeAccountFull) + r.Post("/streaming/support/power_on", server.HandleMargePowerOn) + r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate) + r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets) + r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset) + r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent) + r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice) + r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice) + r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings) + r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken) + r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport) + r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings) + r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings) + r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress) + r.Route("/customer", func(r chi.Router) { r.Get("/account/{account}", server.HandleMargeAccountProfile) r.Post("/account/{account}", server.HandleMargeUpdateAccountProfile) @@ -595,11 +559,7 @@ func setupRouter(server *handlers.Server, scProxy *httputil.ReverseProxy, enable r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents) }) - if enableSoundcorkProxy { - r.NotFound(func(w http.ResponseWriter, r *http.Request) { - scProxy.ServeHTTP(w, r) - }) - } + r.NotFound(server.HandleNotFound) return r } diff --git a/pkg/service/handlers/handlers_proxy.go b/pkg/service/handlers/handlers_proxy.go index 69f26d5..79a37e1 100644 --- a/pkg/service/handlers/handlers_proxy.go +++ b/pkg/service/handlers/handlers_proxy.go @@ -1,6 +1,10 @@ package handlers import ( + "bytes" + "crypto/tls" + "io" + "log" "net/http" "net/http/httputil" "net/url" @@ -33,33 +37,155 @@ func (s *Server) HandleProxyRequest(w http.ResponseWriter, r *http.Request) { return } - lp := proxy.NewLoggingProxy(target.String(), s.proxyRedact) - lp.LogBody = s.proxyLogBody - lp.RecordEnabled = s.recordEnabled - lp.SetRecorder(s.recorder) + s.ServeProxy(target)(w, r) +} - proxy := httputil.NewSingleHostReverseProxy(target) - // Update director to set the correct host and path - originalDirector := proxy.Director - proxy.Director = func(req *http.Request) { - originalDirector(req) - req.Host = target.Host - req.URL.Path = target.Path - req.URL.RawQuery = r.URL.RawQuery - lp.LogRequest(req) - } +// ServeProxy returns a handler that proxies to the given target. +func (s *Server) ServeProxy(target *url.URL) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + lp := proxy.NewLoggingProxy(target.String(), s.proxyRedact) + lp.LogBody = s.proxyLogBody + lp.RecordEnabled = s.recordEnabled + lp.SetRecorder(s.recorder) - proxy.ModifyResponse = func(res *http.Response) error { - // Generic Header Preservation - if etags, ok := res.Header["Etag"]; ok { - delete(res.Header, "Etag") - res.Header["ETag"] = etags + rp := httputil.NewSingleHostReverseProxy(target) + rp.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } - lp.LogResponse(res) + // Update director to set the correct host and path + originalDirector := rp.Director + rp.Director = func(req *http.Request) { + originalDirector(req) + req.Host = target.Host + // If target has a path, we should probably append or replace. + // For Bose upstream, it's usually just the domain. + if target.Path != "" && target.Path != "/" { + req.URL.Path = target.Path + } - return nil + lp.LogRequest(req) + } + + rp.ModifyResponse = func(res *http.Response) error { + res.Header.Set("X-Proxy-Origin", "upstream") + // Generic Header Preservation + if etags, ok := res.Header["Etag"]; ok { + delete(res.Header, "Etag") + res.Header["ETag"] = etags + } + + lp.LogResponse(res) + + return nil + } + + rp.ServeHTTP(w, r) + } +} + +// HandleNotFound handles requests that don't match any route. +func (s *Server) HandleNotFound(w http.ResponseWriter, r *http.Request) { + if s.enableSoundcorkProxy { + s.HandleSoundcorkWithFallback(w, r) + return } - proxy.ServeHTTP(w, r) + s.HandleBoseProxy(w, r) +} + +// HandleSoundcorkWithFallback tries Soundcork first, then Bose if Soundcork returns 404 or fails. +func (s *Server) HandleSoundcorkWithFallback(w http.ResponseWriter, r *http.Request) { + target, _ := url.Parse(s.soundcorkURL) + + // Buffer request body if any, to allow multiple proxy attempts + var bodyBytes []byte + if r.Body != nil { + bodyBytes, _ = io.ReadAll(r.Body) + _ = r.Body.Close() + } + + // We use a custom response writer to catch 404s + rw := &fallbackResponseWriter{ + ResponseWriter: w, + statusCode: http.StatusOK, + buffer: &bytes.Buffer{}, + } + + // Create a shallow copy of the request to avoid side effects between attempts + r2 := r.Clone(r.Context()) + if bodyBytes != nil { + r2.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } else { + r2.Body = nil + } + + // Remove RequestURI as it's not allowed in client requests + r2.RequestURI = "" + + s.ServeProxy(target)(rw, r2) + + if rw.statusCode == http.StatusNotFound || rw.statusCode == http.StatusBadGateway || rw.statusCode == http.StatusServiceUnavailable { + log.Printf("[PROXY] Soundcork returned %d for %s, falling back to Bose", rw.statusCode, r.URL.Path) + + if !rw.wroteHeader { + // Restore original body if any + if bodyBytes != nil { + r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + + s.HandleBoseProxy(w, r) + } + } +} + +type fallbackResponseWriter struct { + http.ResponseWriter + statusCode int + wroteHeader bool + buffer *bytes.Buffer +} + +func (rw *fallbackResponseWriter) WriteHeader(code int) { + rw.statusCode = code + if code != http.StatusNotFound && code != http.StatusBadGateway && code != http.StatusServiceUnavailable { + rw.wroteHeader = true + rw.ResponseWriter.WriteHeader(code) + } +} + +func (rw *fallbackResponseWriter) Write(b []byte) (int, error) { + if rw.statusCode == http.StatusNotFound || rw.statusCode == http.StatusBadGateway || rw.statusCode == http.StatusServiceUnavailable { + return len(b), nil // Drop the body + } + + rw.wroteHeader = true + + return rw.ResponseWriter.Write(b) +} + +// HandleBoseProxy proxies the request to the Bose upstream. +func (s *Server) HandleBoseProxy(w http.ResponseWriter, r *http.Request) { + host := r.Host + if host == "" { + host = "streaming.bose.com" + } + + // Default to HTTPS for Bose services + scheme := "https" + if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") || strings.HasPrefix(host, "::1") { + scheme = "http" + } + + targetURL := scheme + "://" + host + + target, err := url.Parse(targetURL) + if err != nil { + log.Printf("[PROXY_ERR] Failed to parse target URL %s: %v", targetURL, err) + http.Error(w, "Invalid upstream host", http.StatusBadGateway) + + return + } + + s.ServeProxy(target)(w, r) } diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go index b7efbd3..f010ad3 100644 --- a/pkg/service/handlers/main_test.go +++ b/pkg/service/handlers/main_test.go @@ -1,19 +1,19 @@ package handlers import ( - "net/http" - "net/url" - "github.com/gesellix/bose-soundtouch/pkg/service/datastore" "github.com/go-chi/chi/v5" ) func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) { - target, _ := url.Parse(targetURL) - proxy := &reverseProxy{target: target} - server := &Server{ds: ds} + server := NewServer(ds, nil, "http://localhost:8000", false, false, false, false) + server.SetSoundcorkURL(targetURL) r := chi.NewRouter() + r.Use(server.OriginMiddleware) + r.Use(server.ShortcutMiddleware) + r.Use(server.RecordMiddleware) + r.Get("/", server.HandleRoot) // Setup media and web directories for tests @@ -29,6 +29,13 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback) }) + // Legacy or direct domain calls without /bmx prefix + r.Get("/registry/v1/services", server.HandleBMXRegistry) + r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback) + r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo) + r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast) + r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback) + // Setup Marge for tests r.Route("/marge", func(r chi.Router) { r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders) @@ -48,6 +55,23 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress) }) + // Legacy or direct domain calls without /marge prefix + r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders) + r.Get("/accounts/{account}/full", server.HandleMargeAccountFull) + r.Post("/streaming/support/power_on", server.HandleMargePowerOn) + r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate) + r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets) + r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset) + r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent) + r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice) + r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice) + r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings) + r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken) + r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport) + r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings) + r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings) + r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress) + // Setup Customer for tests r.Route("/customer", func(r chi.Router) { r.Get("/account/{account}", server.HandleMargeAccountProfile) @@ -74,19 +98,12 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) r.Get("/ca.crt", server.HandleGetCACert) }) - r.NotFound(func(w http.ResponseWriter, r *http.Request) { - proxy.ServeHTTP(w, r) - }) + r.NotFound(server.HandleNotFound) return r, server } -type reverseProxy struct { - target *url.URL -} - -func (p *reverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Simplified proxy for testing - w.WriteHeader(http.StatusAccepted) // Custom status to identify proxy hit in tests - _, _ = w.Write([]byte("Proxied to " + p.target.String())) +func init() { + // Silence logger for tests + // log.SetOutput(io.Discard) } diff --git a/pkg/service/handlers/origin_middleware.go b/pkg/service/handlers/origin_middleware.go new file mode 100644 index 0000000..04328bd --- /dev/null +++ b/pkg/service/handlers/origin_middleware.go @@ -0,0 +1,27 @@ +package handlers + +import ( + "log" + "net/http" + "time" + + "github.com/go-chi/chi/v5/middleware" +) + +// OriginMiddleware returns a middleware that logs whether the request was handled "self" or "upstream". +func (s *Server) OriginMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + + next.ServeHTTP(ww, r) + + origin := "self" + if ww.Header().Get("X-Proxy-Origin") != "" { + origin = "upstream" + } + + log.Printf("[LOG] %s %s | %d | %s | %v", r.Method, r.URL.Path, ww.Status(), origin, time.Since(start)) + }) +} diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 853d9be..216f848 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -3,6 +3,7 @@ package handlers import ( "context" "log" + "net/http" "sync" "time" @@ -30,6 +31,7 @@ type Server struct { enableSoundcorkProxy bool shortcuts map[string]int recorder *proxy.Recorder + UpstreamProxy http.Handler Version string Commit string Date string @@ -41,7 +43,7 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, pro ds: ds, sm: sm, serverURL: serverURL, - soundcorkURL: serverURL, + soundcorkURL: "http://localhost:8001", proxyRedact: proxyRedact, proxyLogBody: proxyLogBody, recordEnabled: recordEnabled, @@ -101,6 +103,14 @@ func (s *Server) SetHTTPServerURL(url string) { s.httpsServerURL = url } +// SetSoundcorkURL sets the URL for the Soundcork backend. +func (s *Server) SetSoundcorkURL(url string) { + s.mu.Lock() + defer s.mu.Unlock() + + s.soundcorkURL = url +} + // SetRecorder sets the recorder for the server. func (s *Server) SetRecorder(r *proxy.Recorder) { s.recorder = r diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index 683f4f4..c5c5e45 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -218,6 +218,7 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti "events.api.bosecm.com", "bose-prod.apigee.net", "worldwide.bose.com", + "music.api.bose.com", } var hostsLines []string