Compare commits

...
9 Commits
Author SHA1 Message Date
Tobias Gesellchen 7d76b3fab2 Implement dynamic Bose proxy with detailed origin logging and Soundcork fallback 2026-02-15 22:50:13 +01:00
Tobias Gesellchen 6ca206053f Add session download feature to web UI 2026-02-15 22:20:16 +01:00
Tobias Gesellchen 090eb162fb Fix TypeError in Web UI by renaming proxy-domain to soundcork-url
This commit fixes a JS error in showSummary and migrate functions where they were still trying to access the UI element by its old ID 'proxy-domain' instead of the new 'soundcork-url'.
2026-02-15 22:01:58 +01:00
dependabot[bot] 972824e07f ci(deps): bump the actions-core group with 3 updates
Bumps the actions-core group with 3 updates: [actions/checkout](https://github.com/actions/checkout), [actions/configure-pages](https://github.com/actions/configure-pages) and [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact).


Updates `actions/checkout` from 4 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

Updates `actions/configure-pages` from 4 to 5
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v4...v5)

Updates `actions/upload-pages-artifact` from 3 to 4
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-core
- dependency-name: actions/configure-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-core
- dependency-name: actions/upload-pages-artifact
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-core
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-15 21:57:12 +01:00
dependabot[bot] 1e2148d53b deps(deps): bump the golang group with 4 updates
Bumps the golang group with 4 updates: [golang.org/x/crypto](https://github.com/golang/crypto), [golang.org/x/mod](https://github.com/golang/mod), [golang.org/x/net](https://github.com/golang/net) and [golang.org/x/tools](https://github.com/golang/tools).


Updates `golang.org/x/crypto` from 0.47.0 to 0.48.0
- [Commits](https://github.com/golang/crypto/compare/v0.47.0...v0.48.0)

Updates `golang.org/x/mod` from 0.32.0 to 0.33.0
- [Commits](https://github.com/golang/mod/compare/v0.32.0...v0.33.0)

Updates `golang.org/x/net` from 0.49.0 to 0.50.0
- [Commits](https://github.com/golang/net/compare/v0.49.0...v0.50.0)

Updates `golang.org/x/tools` from 0.41.0 to 0.42.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.41.0...v0.42.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/mod
  dependency-version: 0.33.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/net
  dependency-version: 0.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/tools
  dependency-version: 0.42.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-15 21:56:17 +01:00
Tobias Gesellchen 9a070da1ef Fix data race in RecordMiddleware and improve recorder robustness
This commit addresses the data race detected in TestRecordMiddleware: - Updated Recorder.Record to clone Request and Response objects (including bodies) before background processing. - Ensures background workers can safely access data after the main request handler has finished. - Enabled synchronous recording in handler tests to ensure deterministic results and avoid race conditions.
2026-02-15 21:51:55 +01:00
Tobias Gesellchen d4b518da23 Fix proxy and recorder tests by ensuring synchronous recording during testing
This commit addresses the test failures in pkg/service/proxy: - Ensures synchronous recording in tests by setting RECORDER_ASYNC=false. - Adds a Close() method to the Recorder for proper cleanup. - Fixes a panic in TestRecorder_Record_Redaction caused by race conditions.
2026-02-15 21:51:55 +01:00
Tobias Gesellchen 89bafd97b6 Optimize recording performance and add Soundcork proxy toggle
This commit introduces several key improvements: Performance Optimization (asynchronous recording), Legacy Proxy Control (Soundcork proxy toggle), X-Forwarded-For Sanitization, consistent Soundcork naming across the stack, and various code quality improvements.
2026-02-15 21:51:55 +01:00
Tobias Gesellchen d616bc09fd fix unbound variable (tmp) 2026-02-15 20:48:26 +01:00
20 changed files with 761 additions and 270 deletions
+3 -3
View File
@@ -20,16 +20,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Pages
uses: actions/configure-pages@v4
uses: actions/configure-pages@v5
- name: Build with Jekyll
uses: actions/jekyll-build-pages@v1
with:
source: 'docs/'
destination: '_site'
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
uses: actions/upload-pages-artifact@v4
with:
path: '_site'
- name: Deploy to GitHub Pages
+126 -116
View File
@@ -9,7 +9,6 @@ import (
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
@@ -81,10 +80,15 @@ func main() {
EnvVars: []string{"BIND_ADDR"},
},
&cli.StringFlag{
Name: "target-url",
Usage: "URL for Python-based service components (legacy)",
Name: "soundcork-url",
Usage: "URL for Soundcork-based service components (legacy)",
Value: "http://localhost:8001",
EnvVars: []string{"PYTHON_BACKEND_URL", "TARGET_URL"},
EnvVars: []string{"SOUNDCORK_BACKEND_URL", "TARGET_URL"},
},
&cli.BoolFlag{
Name: "enable-soundcork-proxy",
Usage: "Enable proxying unknown requests to the Soundcork backend",
EnvVars: []string{"ENABLE_SOUNDCORK_PROXY"},
},
&cli.StringFlag{
Name: "data-dir",
@@ -138,47 +142,11 @@ func main() {
config := loadConfig(c)
ds := initDataStore(config.dataDir)
// Load settings from datastore
persisted, err := ds.GetSettings()
persisted := applyPersistedSettings(ds, &config)
settingsExist := err == nil && persisted.ServerURL != ""
if persisted.ServerURL != "" {
config.serverURL = persisted.ServerURL
}
if persisted.ProxyURL != "" {
config.targetURL = persisted.ProxyURL
}
if persisted.HTTPServerURL != "" {
config.httpsServerURL = persisted.HTTPServerURL
}
if persisted.DiscoveryInterval != "" {
if d, durErr := time.ParseDuration(persisted.DiscoveryInterval); durErr == nil {
config.discoveryInterval = d
}
}
config.redact = persisted.RedactLogs || config.redact
config.logBody = persisted.LogBodies || config.logBody
config.record = persisted.RecordInteractions || config.record
if !settingsExist {
if persisted.ServerURL == "" {
log.Printf("Creating default settings.json in %s", config.dataDir)
persisted.ServerURL = config.serverURL
persisted.ProxyURL = config.targetURL
persisted.HTTPServerURL = config.httpsServerURL
persisted.RedactLogs = config.redact
persisted.LogBodies = config.logBody
persisted.RecordInteractions = config.record
persisted.DiscoveryInterval = config.discoveryInterval.String()
persisted.DiscoveryEnabled = true
persisted.Shortcuts = map[string]int{
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
"/sw.js": http.StatusNotFound,
}
_ = ds.SaveSettings(persisted)
persisted = createDefaultSettings(ds, config)
}
// Recalculate domains if settings changed
@@ -191,7 +159,8 @@ 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)
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)
@@ -234,13 +203,11 @@ func main() {
log.Printf("Warning: Failed to setup TLS: %v", err)
}
pyProxy := setupPythonProxy(config.targetURL, config.redact, config.logBody, recorder, server)
startDeviceDiscovery(server)
r := setupRouter(server, pyProxy)
r := setupRouter(server)
log.Printf("Go service starting on %s, proxying to %s", config.serverURL, config.targetURL)
log.Printf("Go service starting on %s, proxying to %s", config.serverURL, config.soundcorkURL)
if tlsConfig != nil {
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
@@ -274,19 +241,20 @@ func showVersionInfo(_ *cli.Context) error {
}
type serviceConfig struct {
port string
bindAddr string
addr string
targetURL string
dataDir string
serverURL string
httpsServerURL string
httpsAddr string
redact bool
logBody bool
record bool
discoveryInterval time.Duration
domains []string
port string
bindAddr string
addr string
soundcorkURL string
dataDir string
serverURL string
httpsServerURL string
httpsAddr string
redact bool
logBody bool
record bool
enableSoundcorkProxy bool
discoveryInterval time.Duration
domains []string
}
func loadConfig(c *cli.Context) serviceConfig {
@@ -298,7 +266,7 @@ func loadConfig(c *cli.Context) serviceConfig {
addr = ":" + port
}
targetURL := c.String("target-url")
soundcorkURL := c.String("soundcork-url")
dataDir := c.String("data-dir")
hostname, _ := os.Hostname()
@@ -330,6 +298,7 @@ func loadConfig(c *cli.Context) serviceConfig {
redact := c.Bool("redact-logs")
logBody := c.Bool("log-bodies")
record := c.Bool("record-interactions")
enableSoundcorkProxy := c.Bool("enable-soundcork-proxy")
discoveryIntervalStr := c.String("discovery-interval")
@@ -341,19 +310,20 @@ func loadConfig(c *cli.Context) serviceConfig {
}
return serviceConfig{
port: port,
bindAddr: bindAddr,
addr: addr,
targetURL: targetURL,
dataDir: dataDir,
serverURL: serverURL,
httpsServerURL: httpsServerURL,
httpsAddr: httpsAddr,
redact: redact,
logBody: logBody,
record: record,
discoveryInterval: discoveryInterval,
domains: domains,
port: port,
bindAddr: bindAddr,
addr: addr,
soundcorkURL: soundcorkURL,
dataDir: dataDir,
serverURL: serverURL,
httpsServerURL: httpsServerURL,
httpsAddr: httpsAddr,
redact: redact,
logBody: logBody,
record: record,
enableSoundcorkProxy: enableSoundcorkProxy,
discoveryInterval: discoveryInterval,
domains: domains,
}
}
@@ -386,6 +356,59 @@ func getDomains(serverURL, httpsServerURL, hostname string) []string {
return domains
}
func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) datastore.Settings {
persisted, err := ds.GetSettings()
if err != nil {
return datastore.Settings{}
}
if persisted.ServerURL != "" {
config.serverURL = persisted.ServerURL
}
if persisted.SoundcorkURL != "" {
config.soundcorkURL = persisted.SoundcorkURL
}
if persisted.HTTPServerURL != "" {
config.httpsServerURL = persisted.HTTPServerURL
}
if persisted.DiscoveryInterval != "" {
if d, durErr := time.ParseDuration(persisted.DiscoveryInterval); durErr == nil {
config.discoveryInterval = d
}
}
config.redact = persisted.RedactLogs || config.redact
config.logBody = persisted.LogBodies || config.logBody
config.record = persisted.RecordInteractions || config.record
config.enableSoundcorkProxy = persisted.EnableSoundcorkProxy || config.enableSoundcorkProxy
return persisted
}
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
settings := datastore.Settings{
ServerURL: config.serverURL,
SoundcorkURL: config.soundcorkURL,
HTTPServerURL: config.httpsServerURL,
RedactLogs: config.redact,
LogBodies: config.logBody,
RecordInteractions: config.record,
DiscoveryInterval: config.discoveryInterval.String(),
DiscoveryEnabled: true,
EnableSoundcorkProxy: config.enableSoundcorkProxy,
Shortcuts: map[string]int{
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
"/sw.js": http.StatusNotFound,
},
}
_ = ds.SaveSettings(settings)
return settings
}
func initDataStore(dataDir string) *datastore.DataStore {
ds := datastore.NewDataStore(dataDir)
if err := ds.Initialize(); err != nil {
@@ -404,42 +427,6 @@ func initCertificateManager(dataDir string) *certmanager.CertificateManager {
return cm
}
func setupPythonProxy(targetURL string, redact, logBody bool, recorder *proxy.Recorder, server *handlers.Server) *httputil.ReverseProxy {
target, err := url.Parse(targetURL)
if err != nil {
log.Fatalf("Failed to parse target URL: %v", err)
}
pyProxy := httputil.NewSingleHostReverseProxy(target)
pyProxy.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
}
originalPyDirector := pyProxy.Director
pyProxy.Director = func(req *http.Request) {
originalPyDirector(req)
currentLp := proxy.NewLoggingProxy(target.String(), redact)
currentLp.LogBody = logBody
currentLp.RecordEnabled = server.GetRecordEnabled()
currentLp.SetRecorder(recorder)
currentLp.LogRequest(req)
}
return pyProxy
}
func startDeviceDiscovery(server *handlers.Server) {
go func() {
for {
@@ -453,9 +440,9 @@ func startDeviceDiscovery(server *handlers.Server) {
}()
}
func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *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)
@@ -479,6 +466,13 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
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)
@@ -497,6 +491,23 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
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)
@@ -542,14 +553,13 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
r.Get("/interaction-stats", server.HandleGetInteractionStats)
r.Get("/interactions", server.HandleListInteractions)
r.Get("/interaction-content", server.HandleGetInteractionContent)
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
})
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
pyProxy.ServeHTTP(w, r)
})
r.NotFound(server.HandleNotFound)
return r
}
+4 -4
View File
@@ -8,16 +8,16 @@ require (
github.com/hashicorp/mdns v1.0.6
github.com/russross/blackfriday/v2 v2.1.0
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.47.0
golang.org/x/crypto v0.48.0
)
require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/tools v0.42.0 // indirect
)
+10 -10
View File
@@ -24,16 +24,16 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -44,8 +44,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -79,8 +79,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -98,6 +98,6 @@ golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+10 -9
View File
@@ -698,15 +698,16 @@ func (ds *DataStore) GetETagForAccount(account, device string) int64 {
// Settings represents the global service settings.
type Settings struct {
ServerURL string `json:"server_url"`
ProxyURL string `json:"proxy_url"`
HTTPServerURL string `json:"https_server_url,omitempty"`
RedactLogs bool `json:"redact_logs"`
LogBodies bool `json:"log_bodies"`
RecordInteractions bool `json:"record_interactions"`
DiscoveryInterval string `json:"discovery_interval,omitempty"`
DiscoveryEnabled bool `json:"discovery_enabled"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
ServerURL string `json:"server_url"`
SoundcorkURL string `json:"soundcork_url"`
HTTPServerURL string `json:"https_server_url,omitempty"`
RedactLogs bool `json:"redact_logs"`
LogBodies bool `json:"log_bodies"`
RecordInteractions bool `json:"record_interactions"`
DiscoveryInterval string `json:"discovery_interval,omitempty"`
DiscoveryEnabled bool `json:"discovery_enabled"`
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
}
// GetSettings retrieves the global service settings.
+1 -1
View File
@@ -382,7 +382,7 @@ func TestSettingsPersistence(t *testing.T) {
settings := Settings{
ServerURL: "http://myserver:8000",
ProxyURL: "http://myproxy:8001",
SoundcorkURL: "http://myproxy:8001",
LogBodies: true,
DiscoveryInterval: "10m",
DiscoveryEnabled: true,
+148 -22
View File
@@ -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)
}
+84 -36
View File
@@ -9,6 +9,8 @@ import (
"strconv"
"time"
"fmt"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
@@ -143,17 +145,25 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
s.mu.RLock()
serverURL, proxyURL, httpsServerURL := s.serverURL, s.proxyURL, s.httpsServerURL
serverURL, soundcorkURL, httpsServerURL := s.serverURL, s.soundcorkURL, s.httpsServerURL
discoveryInterval := s.discoveryInterval.String()
discoveryEnabled := s.discoveryEnabled
enableSoundcorkProxy := s.enableSoundcorkProxy
redact, logBody, record := s.proxyRedact, s.proxyLogBody, s.recordEnabled
shortcuts := s.shortcuts
s.mu.RUnlock()
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"server_url": serverURL,
"proxy_url": proxyURL,
"https_server_url": httpsServerURL,
"discovery_interval": discoveryInterval,
"discovery_enabled": discoveryEnabled,
"server_url": serverURL,
"soundcork_url": soundcorkURL,
"https_server_url": httpsServerURL,
"discovery_interval": discoveryInterval,
"discovery_enabled": discoveryEnabled,
"enable_soundcork_proxy": enableSoundcorkProxy,
"redact_logs": redact,
"log_bodies": logBody,
"record_interactions": record,
"shortcuts": shortcuts,
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
@@ -163,10 +173,12 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
// HandleUpdateSettings updates the service settings.
func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
var settings struct {
ServerURL string `json:"server_url"`
ProxyURL string `json:"proxy_url"`
DiscoveryInterval string `json:"discovery_interval"`
DiscoveryEnabled bool `json:"discovery_enabled"`
ServerURL string `json:"server_url"`
SoundcorkURL string `json:"soundcork_url"`
DiscoveryInterval string `json:"discovery_interval"`
DiscoveryEnabled bool `json:"discovery_enabled"`
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
Shortcuts map[string]int `json:"shortcuts"`
}
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -182,13 +194,18 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
s.serverURL = settings.ServerURL
s.proxyURL = settings.ProxyURL
s.soundcorkURL = settings.SoundcorkURL
if settings.DiscoveryInterval != "" {
s.discoveryInterval = interval
}
s.discoveryEnabled = settings.DiscoveryEnabled
s.enableSoundcorkProxy = settings.EnableSoundcorkProxy
if settings.Shortcuts != nil {
s.shortcuts = settings.Shortcuts
}
if s.sm != nil {
s.sm.ServerURL = settings.ServerURL
}
@@ -202,14 +219,16 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
log.Printf("Saving updated settings to %s/settings.json", s.ds.DataDir)
err = s.ds.SaveSettings(datastore.Settings{
ServerURL: s.serverURL,
ProxyURL: s.proxyURL,
HTTPServerURL: currentHTTPS,
RedactLogs: currentRedact,
LogBodies: currentLogBody,
RecordInteractions: currentRecord,
DiscoveryInterval: s.discoveryInterval.String(),
DiscoveryEnabled: s.discoveryEnabled,
ServerURL: s.serverURL,
SoundcorkURL: s.soundcorkURL,
HTTPServerURL: currentHTTPS,
RedactLogs: currentRedact,
LogBodies: currentLogBody,
RecordInteractions: currentRecord,
DiscoveryInterval: s.discoveryInterval.String(),
DiscoveryEnabled: s.discoveryEnabled,
EnableSoundcorkProxy: s.enableSoundcorkProxy,
Shortcuts: s.shortcuts,
})
s.mu.Unlock()
@@ -513,12 +532,13 @@ func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) {
func (s *Server) HandleGetProxySettings(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
redact, logBody, record := s.GetProxySettings()
redact, logBody, record, enableSoundcorkProxy := s.GetProxySettings()
if err := json.NewEncoder(w).Encode(map[string]bool{
"redact": redact,
"log_body": logBody,
"record": record,
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"redact": redact,
"log_body": logBody,
"record": record,
"enable_soundcork_proxy": enableSoundcorkProxy,
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
@@ -543,9 +563,10 @@ func (s *Server) HandleGetCACert(w http.ResponseWriter, _ *http.Request) {
// HandleUpdateProxySettings updates the proxy settings.
func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Request) {
var settings struct {
Redact bool `json:"redact"`
LogBody bool `json:"log_body"`
Record bool `json:"record"`
Redact bool `json:"redact"`
LogBody bool `json:"log_body"`
Record bool `json:"record"`
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
}
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -556,23 +577,26 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques
s.proxyRedact = settings.Redact
s.proxyLogBody = settings.LogBody
s.recordEnabled = settings.Record
s.enableSoundcorkProxy = settings.EnableSoundcorkProxy
// Persist to datastore
// Access fields directly since we already hold the lock
serverURL, proxyURL, httpsServerURL := s.serverURL, s.proxyURL, s.httpsServerURL
serverURL, soundcorkURL, httpsServerURL := s.serverURL, s.soundcorkURL, s.httpsServerURL
discoveryInterval := s.discoveryInterval.String()
discoveryEnabled := s.discoveryEnabled
log.Printf("Saving updated proxy settings to %s/settings.json", s.ds.DataDir)
err := s.ds.SaveSettings(datastore.Settings{
ServerURL: serverURL,
ProxyURL: proxyURL,
HTTPServerURL: httpsServerURL,
RedactLogs: s.proxyRedact,
LogBodies: s.proxyLogBody,
RecordInteractions: s.recordEnabled,
DiscoveryInterval: discoveryInterval,
DiscoveryEnabled: discoveryEnabled,
ServerURL: serverURL,
SoundcorkURL: soundcorkURL,
HTTPServerURL: httpsServerURL,
RedactLogs: s.proxyRedact,
LogBodies: s.proxyLogBody,
RecordInteractions: s.recordEnabled,
DiscoveryInterval: discoveryInterval,
DiscoveryEnabled: discoveryEnabled,
EnableSoundcorkProxy: s.enableSoundcorkProxy,
Shortcuts: s.shortcuts,
})
s.mu.Unlock()
@@ -857,3 +881,27 @@ func (s *Server) HandleCleanupSessions(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok": true}`))
}
// HandleDownloadSession returns a .tar.gz archive of a recorded interaction session.
func (s *Server) HandleDownloadSession(w http.ResponseWriter, r *http.Request) {
if s.recorder == nil {
http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
return
}
session := chi.URLParam(r, "session")
if session == "" {
http.Error(w, "Session ID is required", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.tar.gz\"", session))
if err := s.recorder.ArchiveSession(session, w); err != nil {
log.Printf("Error archiving session %s: %v", session, err)
// Since we already set headers, if we have an error here it might be partially written.
// But for now, simple error handling.
return
}
}
+3 -3
View File
@@ -98,8 +98,8 @@ func TestProxySettingsAPI(t *testing.T) {
// 3. Test System Settings POST
sysUpdate := map[string]string{
"server_url": "http://new-server:8000",
"proxy_url": "http://new-proxy:8001",
"server_url": "http://new-server:8000",
"soundcork_url": "http://new-proxy:8001",
}
sysBody, err := json.Marshal(sysUpdate)
@@ -121,7 +121,7 @@ func TestProxySettingsAPI(t *testing.T) {
// Verify server state
sURL, pURL, _ := server.GetSettings()
if sURL != "http://new-server:8000" || pURL != "http://new-proxy:8001" {
t.Errorf("POST /setup/settings: Server state did not update: serverURL=%s, proxyURL=%s", sURL, pURL)
t.Errorf("POST /setup/settings: Server state did not update: serverURL=%s, soundcorkURL=%s", sURL, pURL)
}
}
@@ -15,6 +15,7 @@ import (
)
func TestInteractionHandlers(t *testing.T) {
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "interaction-handlers-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
@@ -113,6 +114,7 @@ func TestInteractionHandlers(t *testing.T) {
}
func TestRecordMiddleware(t *testing.T) {
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "record-middleware-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
+34 -17
View File
@@ -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)
}
+27
View File
@@ -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))
})
}
+41 -29
View File
@@ -3,6 +3,7 @@ package handlers
import (
"context"
"log"
"net/http"
"sync"
"time"
@@ -15,36 +16,39 @@ import (
// Server handles HTTP requests for the SoundTouch service.
type Server struct {
ds *datastore.DataStore
sm *setup.Manager
mu sync.RWMutex
serverURL string
proxyURL string
httpsServerURL string
discovering bool
proxyRedact bool
proxyLogBody bool
recordEnabled bool
discoveryInterval time.Duration
discoveryEnabled bool
shortcuts map[string]int
recorder *proxy.Recorder
Version string
Commit string
Date string
ds *datastore.DataStore
sm *setup.Manager
mu sync.RWMutex
serverURL string
soundcorkURL string
httpsServerURL string
discovering bool
proxyRedact bool
proxyLogBody bool
recordEnabled bool
discoveryInterval time.Duration
discoveryEnabled bool
enableSoundcorkProxy bool
shortcuts map[string]int
recorder *proxy.Recorder
UpstreamProxy http.Handler
Version string
Commit string
Date string
}
// NewServer creates a new SoundTouch service server.
func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody, recordEnabled bool) *Server {
func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody, recordEnabled, enableSoundcorkProxy bool) *Server {
return &Server{
ds: ds,
sm: sm,
serverURL: serverURL,
proxyURL: serverURL,
proxyRedact: proxyRedact,
proxyLogBody: proxyLogBody,
recordEnabled: recordEnabled,
discoveryInterval: 5 * time.Minute,
ds: ds,
sm: sm,
serverURL: serverURL,
soundcorkURL: "http://localhost:8001",
proxyRedact: proxyRedact,
proxyLogBody: proxyLogBody,
recordEnabled: recordEnabled,
enableSoundcorkProxy: enableSoundcorkProxy,
discoveryInterval: 5 * time.Minute,
}
}
@@ -99,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
@@ -117,15 +129,15 @@ func (s *Server) GetSettings() (string, string, string) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.serverURL, s.proxyURL, s.httpsServerURL
return s.serverURL, s.soundcorkURL, s.httpsServerURL
}
// GetProxySettings returns the current proxy settings.
func (s *Server) GetProxySettings() (bool, bool, bool) {
func (s *Server) GetProxySettings() (bool, bool, bool, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.proxyRedact, s.proxyLogBody, s.recordEnabled
return s.proxyRedact, s.proxyLogBody, s.recordEnabled, s.enableSoundcorkProxy
}
// DiscoverDevices starts a background device discovery process.
+4 -3
View File
@@ -87,9 +87,9 @@
<span style="font-size: 0.8em; color: #666;">(Standard services URL)</span>
</div>
<div style="margin-bottom: 20px;">
<label for="proxy-domain">Proxy Domain:</label>
<input type="text" id="proxy-domain" placeholder="http://192.168.x.x:8000" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(Upstream proxy URL - usually the same as Target Domain)</span>
<label for="soundcork-url">Soundcork URL:</label>
<input type="text" id="soundcork-url" placeholder="http://192.168.x.x:8001" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(Soundcork services URL)</span>
</div>
<div style="margin-bottom: 20px;">
<label for="discovery-interval">Discovery Interval:</label>
@@ -105,6 +105,7 @@
<div style="margin-top: 5px;">
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="enable-soundcork-proxy" onchange="updateProxySettings()"> Enable Soundcork Proxy (Legacy)</label>
<label style="display: block; margin-bottom: 5px;">
<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>
+20 -6
View File
@@ -6,7 +6,7 @@ async function fetchSettings() {
document.getElementById('target-domain').value = settings.server_url;
}
if (settings.proxy_url) {
document.getElementById('proxy-domain').value = settings.proxy_url;
document.getElementById('soundcork-url').value = settings.proxy_url;
}
if (settings.discovery_interval) {
document.getElementById('discovery-interval').value = settings.discovery_interval;
@@ -14,6 +14,9 @@ async function fetchSettings() {
if (settings.discovery_enabled !== undefined) {
document.getElementById('discovery-enabled').checked = settings.discovery_enabled;
}
if (settings.enable_soundcork_proxy !== undefined) {
document.getElementById('enable-soundcork-proxy').checked = settings.enable_soundcork_proxy;
}
fetchProxySettings();
} catch (error) {
console.error('Failed to fetch settings', error);
@@ -27,6 +30,9 @@ async function fetchProxySettings() {
document.getElementById('proxy-redact').checked = settings.redact;
document.getElementById('proxy-log-body').checked = settings.log_body;
document.getElementById('proxy-record').checked = settings.record;
if (settings.enable_soundcork_proxy !== undefined) {
document.getElementById('enable-soundcork-proxy').checked = settings.enable_soundcork_proxy;
}
} catch (error) {
console.error('Failed to fetch proxy settings', error);
}
@@ -36,7 +42,8 @@ async function updateProxySettings() {
const settings = {
redact: document.getElementById('proxy-redact').checked,
log_body: document.getElementById('proxy-log-body').checked,
record: document.getElementById('proxy-record').checked
record: document.getElementById('proxy-record').checked,
enable_soundcork_proxy: document.getElementById('enable-soundcork-proxy').checked
};
try {
await fetch('/setup/proxy-settings', {
@@ -52,9 +59,10 @@ async function updateProxySettings() {
async function updateSettings() {
const settings = {
server_url: document.getElementById('target-domain').value,
proxy_url: document.getElementById('proxy-domain').value,
proxy_url: document.getElementById('soundcork-url').value,
discovery_interval: document.getElementById('discovery-interval').value,
discovery_enabled: document.getElementById('discovery-enabled').checked
discovery_enabled: document.getElementById('discovery-enabled').checked,
enable_soundcork_proxy: document.getElementById('enable-soundcork-proxy').checked
};
const status = document.getElementById('settings-status');
status.innerText = 'Saving...';
@@ -313,6 +321,7 @@ async function fetchInteractionStats() {
li.innerHTML = `
<span class="session-info"><strong>${sessionDisplay}:</strong> ${count || 0} requests</span>
<div style="display: flex; gap: 5px;">
<button onclick="downloadSession('${session || ""}')" class="btn-info" style="font-size: 0.8em; padding: 2px 5px;">Download</button>
<button onclick="filterBySession('${session || ""}')" style="font-size: 0.8em; padding: 2px 5px;">Filter</button>
<button onclick="deleteSession('${session || ""}')" class="btn-danger" style="font-size: 0.8em; padding: 2px 5px;">Delete</button>
</div>
@@ -332,6 +341,11 @@ async function fetchInteractionStats() {
}
}
function downloadSession(sessionId) {
if (!sessionId) return;
window.location.href = `/setup/interactions/sessions/${sessionId}/download`;
}
async function filterBySession(sessionId) {
document.getElementById('filter-session').value = sessionId;
fetchInteractions();
@@ -668,7 +682,7 @@ async function showSummary(ip) {
return;
}
const targetUrl = document.getElementById('target-domain').value;
const proxyUrl = document.getElementById('proxy-domain').value;
const proxyUrl = document.getElementById('soundcork-url').value;
const opts = {
marge: document.getElementById('opt-marge').value,
@@ -918,7 +932,7 @@ async function migrate(ip) {
return;
}
const targetUrl = document.getElementById('target-domain').value;
const proxyUrl = document.getElementById('proxy-domain').value;
const proxyUrl = document.getElementById('soundcork-url').value;
const method = document.getElementById('migration-method').value;
const opts = {
+1
View File
@@ -88,6 +88,7 @@ func TestLoggingProxy_LogRequest(t *testing.T) {
}
func TestLoggingProxy_LogResponse(t *testing.T) {
t.Setenv("RECORDER_ASYNC", "false")
lp := NewLoggingProxy("http://example.com", true)
lp.LogBody = true
+170 -9
View File
@@ -1,10 +1,13 @@
package proxy
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
@@ -25,6 +28,16 @@ type Recorder struct {
counter uint64
variables map[string]string
mu sync.Mutex
queue chan recordingTask
}
type recordingTask struct {
category string
req *http.Request
res *http.Response
replacements map[string]string
dir string
path string
}
// InteractionStats represents statistics for recorded interactions.
@@ -51,15 +64,34 @@ type Interaction struct {
func NewRecorder(baseDir string) *Recorder {
sessionID := time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%d", os.Getpid())
return &Recorder{
r := &Recorder{
BaseDir: baseDir,
SessionID: sessionID,
Patterns: DefaultPatterns(),
variables: make(map[string]string),
}
// Use environment variable to control async recording, default to true for production
// but allow disabling it for tests if needed.
if os.Getenv("RECORDER_ASYNC") != "false" {
r.queue = make(chan recordingTask, 100)
go r.worker()
} else {
log.Println("[DEBUG_LOG] Recorder starting in synchronous mode")
}
return r
}
// Record persists a request and response to a .http file in the specified category (e.g., "self" or "upstream").
// Close stops the recorder and waits for pending tasks to finish.
func (r *Recorder) Close() {
if r.queue != nil {
close(r.queue)
// We might want to wait here, but for now just closing is a start
}
}
// Record logs an interaction to the configured category.
func (r *Recorder) Record(category string, req *http.Request, res *http.Response) error {
if r.BaseDir == "" {
return nil
@@ -74,19 +106,82 @@ func (r *Recorder) Record(category string, req *http.Request, res *http.Response
path := r.getRecordingPath(dir, req.Method)
// If we are in async mode, we MUST copy the bodies now because the caller
// might close them as soon as Record() returns.
var (
clonedReq *http.Request
clonedRes *http.Response
)
if r.queue != nil {
// Clone request
clonedReq = req.Clone(req.Context())
if req.Body != nil {
bodyBytes, _ := io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
clonedReq.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
// Clone response if present
if res != nil {
clonedRes = &http.Response{
StatusCode: res.StatusCode,
Header: res.Header.Clone(),
Request: clonedReq,
}
if res.Body != nil {
bodyBytes, _ := io.ReadAll(res.Body)
res.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
clonedRes.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
}
} else {
clonedReq = req
clonedRes = res
}
task := recordingTask{
category: category,
req: clonedReq,
res: clonedRes,
replacements: replacements,
dir: dir,
path: path,
}
// For testing purposes or if queue is nil, fallback to synchronous
if r.queue == nil {
r.save(task)
return nil
}
select {
case r.queue <- task:
return nil
default:
return fmt.Errorf("recording queue full, dropping interaction for %s", req.URL.Path)
}
}
func (r *Recorder) save(task recordingTask) {
var buf bytes.Buffer
r.writeRequest(&buf, task.req, task.replacements)
r.writeRequest(&buf, req, replacements)
if res != nil {
r.writeResponse(&buf, res)
if task.res != nil {
r.writeResponse(&buf, task.res)
}
if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
return err
if err := os.WriteFile(task.path, buf.Bytes(), 0644); err != nil {
log.Printf("failed to write recording to %s: %v", task.path, err)
}
return r.updateEnvFile(replacements)
_ = r.updateEnvFile(task.replacements)
}
func (r *Recorder) worker() {
for task := range r.queue {
r.save(task)
}
}
func (r *Recorder) getSanitizedSegments(path string) ([]string, map[string]string) {
@@ -486,3 +581,69 @@ func (r *Recorder) GetInteractionContent(relPath string) ([]byte, error) {
fullPath := filepath.Join(r.BaseDir, "interactions", relPath)
return os.ReadFile(fullPath)
}
// ArchiveSession creates a .tar.gz archive of the specified session and writes it to w.
func (r *Recorder) ArchiveSession(sessionID string, w io.Writer) (err error) {
sessionDir := filepath.Join(r.BaseDir, "interactions", sessionID)
info, statErr := os.Stat(sessionDir)
if statErr != nil {
return statErr
}
if !info.IsDir() {
return fmt.Errorf("%s is not a directory", sessionID)
}
gw := gzip.NewWriter(w)
defer func() {
if closeErr := gw.Close(); closeErr != nil && err == nil {
err = closeErr
}
}()
tw := tar.NewWriter(gw)
defer func() {
if closeErr := tw.Close(); closeErr != nil && err == nil {
err = closeErr
}
}()
return filepath.Walk(sessionDir, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
header, hErr := tar.FileInfoHeader(info, info.Name())
if hErr != nil {
return hErr
}
rel, rErr := filepath.Rel(sessionDir, path)
if rErr != nil {
return rErr
}
header.Name = rel
if whErr := tw.WriteHeader(header); whErr != nil {
return whErr
}
if !info.Mode().IsRegular() {
return nil
}
f, oErr := os.Open(path)
if oErr != nil {
return oErr
}
defer f.Close()
_, cErr := io.Copy(tw, f)
return cErr
})
}
+70
View File
@@ -1,7 +1,9 @@
package proxy
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
@@ -15,6 +17,7 @@ import (
)
func TestRecorder_Record_Structure(t *testing.T) {
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "recorder-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
@@ -105,6 +108,7 @@ func TestRecorder_Record_Structure(t *testing.T) {
}
func TestRecorder_Record_Sanitization(t *testing.T) {
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "recorder-sanitization-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
@@ -163,6 +167,7 @@ func TestRecorder_Record_Sanitization(t *testing.T) {
}
func TestRecorder_Record_Sanitization_Account(t *testing.T) {
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "recorder-sanitization-account-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
@@ -217,6 +222,7 @@ func TestRecorder_Record_Sanitization_Account(t *testing.T) {
}
func TestRecorder_Record_Redaction(t *testing.T) {
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "recorder-redaction-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
@@ -266,6 +272,7 @@ func isDigit(c byte) bool {
}
func TestRecorder_IncreasingPrefix(t *testing.T) {
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "recorder-prefix-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
@@ -308,6 +315,7 @@ func TestRecorder_IncreasingPrefix(t *testing.T) {
}
func TestRecorder_EnvFile(t *testing.T) {
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "recorder-env-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
@@ -350,6 +358,7 @@ func TestRecorder_EnvFile(t *testing.T) {
}
func TestRecorder_GetInteractionStats(t *testing.T) {
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "recorder-stats-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
@@ -395,6 +404,7 @@ func TestRecorder_GetInteractionStats(t *testing.T) {
}
func TestRecorder_ListInteractions(t *testing.T) {
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "recorder-list-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
@@ -619,7 +629,66 @@ func TestRecorder_GetInteractionContent(t *testing.T) {
}
}
func TestRecorder_ArchiveSession(t *testing.T) {
tmpDir, _ := os.MkdirTemp("", "archive-test")
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
sessionID := "test-session-123"
sessionDir := filepath.Join(tmpDir, "interactions", sessionID)
os.MkdirAll(filepath.Join(sessionDir, "category1"), 0755)
os.WriteFile(filepath.Join(sessionDir, "category1", "file1.http"), []byte("content1"), 0644)
os.WriteFile(filepath.Join(sessionDir, "file2.http"), []byte("content2"), 0644)
var buf bytes.Buffer
err := r.ArchiveSession(sessionID, &buf)
if err != nil {
t.Fatalf("ArchiveSession failed: %v", err)
}
if buf.Len() == 0 {
t.Fatal("Archive buffer is empty")
}
// Verify it's a valid tar.gz
gr, err := gzip.NewReader(&buf)
if err != nil {
t.Fatalf("Failed to create gzip reader: %v", err)
}
defer gr.Close()
tr := tar.NewReader(gr)
files := make(map[string]string)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("Failed to read tar: %v", err)
}
if header.Typeflag == tar.TypeReg {
var b bytes.Buffer
io.Copy(&b, tr)
files[header.Name] = b.String()
}
}
if len(files) != 2 {
t.Errorf("Expected 2 files in archive, got %d", len(files))
}
if files["category1/file1.http"] != "content1" {
t.Errorf("Unexpected content for category1/file1.http: %s", files["category1/file1.http"])
}
if files["file2.http"] != "content2" {
t.Errorf("Unexpected content for file2.http: %s", files["file2.http"])
}
}
func TestRecorder_Record_FullExchange(t *testing.T) {
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "recorder-full-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
@@ -673,6 +742,7 @@ func TestRecorder_Record_FullExchange(t *testing.T) {
}
func TestRecorder_Record_BinaryResponse(t *testing.T) {
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "recorder-binary-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
+1
View File
@@ -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
+2 -2
View File
@@ -28,7 +28,7 @@ set -euo pipefail
# - Safe to re-run; it will update binary/config/unit and restart the service.
# ==============================================================================
VERSION="${1:-${VERSION:-v0.18.1}}"
VERSION="${1:-${VERSION:-v0.24.0}}"
# Normalize version prefix
if [[ ! "$VERSION" =~ ^v ]]; then
VERSION="v${VERSION}"
@@ -141,7 +141,7 @@ ensure_dirs() {
}
download_binary() {
local asset url tmp
local asset url tmp=""
asset="${ARCH_ASSET:-$(detect_arch_asset)}"
url="$(download_url_for "$asset")"