Compare commits

...
14 Commits
Author SHA1 Message Date
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
Tobias Gesellchen 8af60c7e4b fix linter issues 2026-02-15 20:20:47 +01:00
Tobias Gesellchen 8a21db3517 Capture additional redirect methods and improve recorder functionality 2026-02-15 20:20:47 +01:00
Tobias Gesellchen 742484568e feat: implement Stockholm-related cloud API emulation - Added handlers for Stockholm app events (/v1/stapp, /v1/scmudc) - Implemented account profile, password management, and device settings endpoints - Added Go models for new API responses and requests - Created docs/reference/CLOUD-API.md and updated SUMMARY.md - Added comprehensive unit tests for all new handlers - Updated ueberboese-api.yaml with new endpoints and schemas 2026-02-15 20:20:47 +01:00
Tobias Gesellchen ed2d8680e4 fix: align streaming_token with Bose protocol to avoid 502 errors 2026-02-15 18:58:35 +01:00
Tobias Gesellchen 6dc8c23f04 feat: detect migrated devices and prompt for reboot after migration 2026-02-15 18:58:35 +01:00
Tobias Gesellchen fa57ee9574 Rebrand to AfterTouch and cleanup SoundCork references 2026-02-15 18:09:49 +01:00
Tobias Gesellchen e438db05d9 Fix release workflow to avoid +dirty version suffix by building in isolated directory 2026-02-15 17:27:41 +01:00
54 changed files with 1260 additions and 281 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
+7 -4
View File
@@ -133,10 +133,13 @@ jobs:
local CMD_PATH=$2
local OUTPUT_NAME
# Ensure build directory exists
mkdir -p build
if [[ "${{ matrix.goos }}" == "windows" ]]; then
OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}.exe"
OUTPUT_NAME="build/${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}.exe"
else
OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}"
OUTPUT_NAME="build/${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}"
fi
echo "Building $BINARY_NAME: $OUTPUT_NAME"
@@ -193,8 +196,8 @@ jobs:
with:
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
path: |
soundtouch-cli-v*
soundtouch-service-v*
build/soundtouch-cli-v*
build/soundtouch-service-v*
retention-days: 1
checksums:
+1 -1
View File
@@ -481,7 +481,7 @@ This project builds upon the excellent work of several community projects:
### SoundCork 🍾
- **Project**: [SoundCork - SoundTouch API Intercept](https://github.com/deborahgu/soundcork)
- **Authors**: Deborah Kaplan and contributors
- **Our Implementation**: The `soundtouch-service` in this project is heavily inspired by and based on SoundCork's Python implementation. SoundCork pioneered the approach of intercepting and emulating Bose's cloud services, providing the foundation for offline SoundTouch operation.
- **Our Implementation**: The `soundtouch-service` in this project is heavily inspired by SoundCork's Python implementation. SoundCork pioneered the approach of intercepting and emulating Bose's cloud services, providing the foundation for offline SoundTouch operation.
- **Key Contributions**: Service emulation architecture, BMX/Marge endpoint discovery, device migration strategies
- **License**: MIT License
+149 -86
View File
@@ -81,10 +81,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 +143,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 +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)
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.enableSoundcorkProxy)
server.SetHTTPServerURL(config.httpsServerURL)
server.SetVersionInfo(version, commit, date)
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
@@ -234,13 +203,13 @@ func main() {
log.Printf("Warning: Failed to setup TLS: %v", err)
}
pyProxy := setupPythonProxy(config.targetURL, config.redact, config.logBody, recorder, server)
scProxy := setupSoundcorkProxy(config.soundcorkURL, config.redact, config.logBody, recorder, server)
startDeviceDiscovery(server)
r := setupRouter(server, pyProxy)
r := setupRouter(server, scProxy, config.enableSoundcorkProxy)
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 +243,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 +268,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 +300,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 +312,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 +358,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,14 +429,14 @@ 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)
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 target URL: %v", err)
log.Fatalf("Failed to parse Soundcork URL: %v", err)
}
pyProxy := httputil.NewSingleHostReverseProxy(target)
pyProxy.ModifyResponse = func(res *http.Response) error {
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
@@ -426,9 +451,31 @@ func setupPythonProxy(targetURL string, redact, logBody bool, recorder *proxy.Re
return nil
}
originalPyDirector := pyProxy.Director
pyProxy.Director = func(req *http.Request) {
originalPyDirector(req)
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
@@ -437,7 +484,7 @@ func setupPythonProxy(targetURL string, redact, logBody bool, recorder *proxy.Re
currentLp.LogRequest(req)
}
return pyProxy
return scProxy
}
func startDeviceDiscovery(server *handlers.Server) {
@@ -453,7 +500,7 @@ func startDeviceDiscovery(server *handlers.Server) {
}()
}
func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.Mux {
func setupRouter(server *handlers.Server, scProxy *httputil.ReverseProxy, enableSoundcorkProxy bool) *chi.Mux {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
@@ -492,6 +539,20 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
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)
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
})
r.Route("/v1", func(r chi.Router) {
r.Post("/stapp/{deviceId}", server.HandleAppEvents)
r.Post("/scmudc/{deviceId}", server.HandleAppEvents)
})
r.Route("/streaming/stats", func(r chi.Router) {
@@ -533,9 +594,11 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
})
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
pyProxy.ServeHTTP(w, r)
})
if enableSoundcorkProxy {
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
scProxy.ServeHTTP(w, r)
})
}
return r
}
+1
View File
@@ -24,6 +24,7 @@
## Technical Reference
* [API Cookbook](reference/API-COOKBOOK.md)
* [API Endpoints](reference/API-ENDPOINTS.md)
* [Cloud API Emulation](reference/CLOUD-API.md)
* [System Endpoints](reference/SYSTEM-ENDPOINTS.md)
* [Speaker Endpoint](reference/SPEAKER-ENDPOINT.md)
* [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
+4 -1
View File
@@ -9,6 +9,9 @@ SoundTouch devices primarily communicate with the following domains:
- `updates.bose.com`: Software updates
- `stats.bose.com`: Telemetry and analytics
- `bmx.bose.com`: Bose Media eXchange registry
- `events.api.bosecm.com`: Stockholm app analytics
- `bose-prod.apigee.net`: Apigee gateway (used by some services)
- `worldwide.bose.com`: Software update metadata and secondary services
---
@@ -153,7 +156,7 @@ For developers creating a completely isolated "dark" environment (no internet at
1. **XML**: Point all URLs to local services.
2. **Binary Patch**: Neutralize `IsItBose` to allow non-Bose domains/IPs.
3. **`/etc/hosts`**: Redirect hardcoded domains that aren't exposed in the XML (like analytics or NTP) to prevent leakage to the real Bose cloud.
4. **Process Instrumentation**: Use [SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook) to monitor and override internal behavior in real-time.
4. **Process Instrumentation**: Use [SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook) to monitor and override internal behavior in real-time. This is particularly useful for handling unknown hostnames or deep-hooking into service discovery logic that might bypass standard DNS lookups.
---
+4 -4
View File
@@ -1,6 +1,6 @@
# HTTPS Setup & Custom CA Certificate
To use the `/etc/hosts` redirection method safely, SoundTouch devices must communicate over HTTPS. This requires the device to trust the Root CA certificate used by the local `soundtouch-service`.
To use the `/etc/hosts` redirection method safely, SoundTouch devices must communicate over HTTPS. This requires the device to trust the AfterTouch Root CA certificate used by the local service.
## 1. Automated Migration (Hosts Method)
@@ -13,12 +13,12 @@ curl -X POST "http://localhost:8000/setup/migrate/{deviceIP}?method=hosts"
This command will:
1. Connect to the device via SSH.
2. Update `/etc/hosts` to point Bose domains to the service IP.
3. Inject the auto-generated Root CA into the device's trust store (`/etc/pki/tls/certs/ca-bundle.crt`).
3. Inject the auto-generated AfterTouch Root CA into the device's trust store (`/etc/pki/tls/certs/ca-bundle.crt`).
4. Reboot the device.
## 2. Managing the Root CA
The `soundtouch-service` automatically generates a Root CA when it first starts.
The AfterTouch service automatically generates a Root CA when it first starts.
- **CA Certificate**: `data/certs/ca.crt`
- **CA Private Key**: `data/certs/ca.key`
@@ -34,7 +34,7 @@ The `soundtouch-service` now includes a built-in HTTPS listener. This simplifies
- **HTTPS Port**: Configurable via `HTTPS_PORT` environment variable (defaults to `8443`).
- **HTTPS Server URL**: Configurable via `HTTPS_SERVER_URL` (e.g., `https://mysoundtouch.local:8443`). If not set, the service attempts to guess it using the system hostname.
- **Domain Coverage**: Automatically presents a certificate for `streaming.bose.com`, `updates.bose.com`, `stats.bose.com`, `bmx.bose.com`, and `content.api.bose.io`.
- **Automatic Setup**: On first start, it generates a server certificate signed by your local Root CA.
- **Automatic Setup**: On first start, it generates a server certificate signed by your AfterTouch local Root CA.
#### TLS Security
+73
View File
@@ -0,0 +1,73 @@
# Bose SoundTouch Cloud API Emulation (Marge/BMX/Stats)
This document describes the cloud-emulation APIs provided by the SoundTouch service. These APIs mimic the Bose cloud services (Marge, BMX, Stats) that SoundTouch devices and the SoundTouch controller application (Stockholm) interact with.
## Marge API (Account & Configuration)
Base path: `/marge`
### GET /streaming/sourceproviders
Retrieves a list of available streaming source providers.
### GET /accounts/{accountId}/full
Retrieves the full account configuration including sources, presets, and devices.
### GET /streaming/account/{accountId}/emailaddress
Retrieves the email address associated with the account.
### GET /streaming/device_setting/account/{accountId}/device/{deviceId}/device_settings
Retrieves settings for a specific device (e.g., clock format).
### POST /streaming/device_setting/account/{accountId}/device/{deviceId}/device_settings
Updates settings for a specific device.
### POST /accounts/{accountId}/devices/{deviceId}/presets/{presetNumber}
Updates a preset for a device.
### POST /accounts/{accountId}/devices/{deviceId}/recents
Adds an item to the device's recently played history.
### POST /accounts/{accountId}/devices
Adds a device to the account.
### DELETE /accounts/{accountId}/devices/{deviceId}
Removes a device from the account.
## Customer API (Profile & Password)
Base path: `/customer`
### GET /account/{accountId}
Retrieves the customer account profile.
### POST /account/{accountId}
Updates the customer account profile.
### POST /account/{accountId}/password
Changes the account password.
## Analytics & Stats API
Base path: `/v1` (App Events) or `/streaming/stats` (Device Stats)
### POST /v1/stapp/{deviceId}
Endpoint called by Bose SoundTouch mobile and web applications (Stockholm) to submit event data.
### POST /v1/scmudc/{deviceId}
Endpoint equivalent to `/v1/stapp/{deviceId}` sometimes used by apps or devices.
### POST /streaming/stats/usage
Endpoint used by physical devices to report usage statistics.
### POST /streaming/stats/error
Endpoint used by physical devices to report error statistics.
## BMX API (Streaming & Registry)
Base path: `/bmx`
### GET /registry/v1/services
Retrieves the registry of available streaming services.
### GET /tunein/v1/playback/station/{stationID}
Retrieves playback information for a TuneIn station.
+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=
+67
View File
@@ -240,3 +240,70 @@ type DeviceEvent struct {
MonoTime int64 `json:"monoTime"`
Data map[string]interface{} `json:"data"`
}
// DeviceEventsRequest represents a request containing multiple device events (stapp/scmudc).
type DeviceEventsRequest struct {
Envelope struct {
MonoTime int64 `json:"monoTime"`
PayloadProtocolVersion string `json:"payloadProtocolVersion"`
PayloadType string `json:"payloadType"`
ProtocolVersion string `json:"protocolVersion"`
Time string `json:"time"`
UniqueID string `json:"uniqueId"`
} `json:"envelope"`
Payload struct {
DeviceInfo struct {
BoseID string `json:"boseID"`
DeviceID string `json:"deviceID"`
DeviceType string `json:"deviceType"`
SoftwareVersion string `json:"softwareVersion"`
} `json:"deviceInfo"`
Events []struct {
Data map[string]interface{} `json:"data"`
Time string `json:"time"`
Type string `json:"type"`
} `json:"events"`
} `json:"payload"`
}
// DeviceSettingsResponse represents device settings.
type DeviceSettingsResponse struct {
XMLName xml.Name `xml:"deviceSettings"`
Settings []DeviceSetting `xml:"deviceSetting"`
}
// DeviceSetting represents a single device setting.
type DeviceSetting struct {
Name string `xml:"name"`
Value string `xml:"value"`
}
// AccountProfileResponse represents a customer account profile.
type AccountProfileResponse struct {
XMLName xml.Name `xml:"customer"`
AccountID string `xml:"accountID"`
Email string `xml:"email"`
FirstName string `xml:"firstName"`
LastName string `xml:"lastName"`
CountryCode string `xml:"countryCode"`
LanguageCode string `xml:"languageCode"`
Street string `xml:"street"`
City string `xml:"city"`
PostalCode string `xml:"postalCode"`
State string `xml:"state"`
Phone string `xml:"phone"`
MarketingOptIn bool `xml:"marketingOptIn"`
}
// ChangePasswordRequest represents a request to change the account password.
type ChangePasswordRequest struct {
XMLName xml.Name `xml:"passwordChange"`
OldPassword string `xml:"oldPassword"`
NewPassword string `xml:"newPassword"`
}
// EmailAddressResponse represents the account email address.
type EmailAddressResponse struct {
XMLName xml.Name `xml:"emailAddress"`
Email string `xml:",chardata"`
}
+3 -3
View File
@@ -148,8 +148,8 @@ func (cm *CertificateManager) GenerateCA() error {
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"SoundTouch Local Service"},
CommonName: "SoundTouch Local Root CA",
Organization: []string{"AfterTouch"},
CommonName: "AfterTouch Local Root CA",
},
NotBefore: notBefore,
NotAfter: notAfter,
@@ -240,7 +240,7 @@ func (cm *CertificateManager) GenerateCertificate(domains []string) ([]byte, []b
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"SoundTouch Local Service"},
Organization: []string{"AfterTouch"},
CommonName: domains[0],
},
NotBefore: notBefore,
+12 -11
View File
@@ -158,8 +158,8 @@ func (ds *DataStore) getPossibleDataDirs() []string {
dirs = append(dirs, filepath.Join(ds.DataDir, "accounts"))
}
// Also check soundcork-go/data/accounts if it's different and exists
altDir := "soundcork-go/data/accounts"
// Also check st-go/data/accounts if it's different and exists
altDir := "st-go/data/accounts"
if filepath.Join(ds.DataDir, "accounts") != altDir && exists(altDir) {
dirs = append(dirs, altDir)
}
@@ -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.
+7 -7
View File
@@ -9,7 +9,7 @@ import (
)
func TestDataStore(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatal(err)
}
@@ -95,7 +95,7 @@ func TestDataStore(t *testing.T) {
}
func TestListAllDevices_Empty(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-empty-test-*")
tempDir, err := os.MkdirTemp("", "st-empty-test-*")
if err != nil {
t.Fatal(err)
}
@@ -134,7 +134,7 @@ func TestListAllDevices_Empty(t *testing.T) {
}
func TestListAllDevices(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-list-test-*")
tempDir, err := os.MkdirTemp("", "st-list-test-*")
if err != nil {
t.Fatal(err)
}
@@ -175,7 +175,7 @@ func TestListAllDevices(t *testing.T) {
}
func TestListAllDevices_EmptyDeviceID(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-empty-id-test-*")
tempDir, err := os.MkdirTemp("", "st-empty-id-test-*")
if err != nil {
t.Fatal(err)
}
@@ -218,7 +218,7 @@ func TestListAllDevices_EmptyDeviceID(t *testing.T) {
}
func TestListAllDevices_MultipleEmptyIDs(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-multi-empty-test-*")
tempDir, err := os.MkdirTemp("", "st-multi-empty-test-*")
if err != nil {
t.Fatal(err)
}
@@ -264,7 +264,7 @@ func TestListAllDevices_MultipleEmptyIDs(t *testing.T) {
}
func TestListAllDevices_MalformedXML(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-malformed-test-*")
tempDir, err := os.MkdirTemp("", "st-malformed-test-*")
if err != nil {
t.Fatal(err)
}
@@ -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,
+1 -1
View File
@@ -16,7 +16,7 @@ const normalizedEtag = "Etag"
const caseSensitiveETag = "ETag"
func TestMargeETags(t *testing.T) {
tempDir, _ := os.MkdirTemp("", "soundcork-etag-test-*")
tempDir, _ := os.MkdirTemp("", "st-etag-test-*")
defer func() { _ = os.RemoveAll(tempDir) }()
+93 -3
View File
@@ -60,6 +60,85 @@ func (s *Server) HandleMargePowerOn(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}
// HandleMargeAccountProfile returns the account profile.
func (s *Server) HandleMargeAccountProfile(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "account")
// Mock profile data
profile := models.AccountProfileResponse{
AccountID: accountID,
Email: "user@example.com",
FirstName: "SoundTouch",
LastName: "User",
CountryCode: "US",
LanguageCode: "en",
}
data, err := xml.MarshalIndent(profile, "", " ")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(xml.Header))
_, _ = w.Write(data)
}
// HandleMargeUpdateAccountProfile updates the account profile.
func (s *Server) HandleMargeUpdateAccountProfile(w http.ResponseWriter, _ *http.Request) {
// Stub implementation
w.WriteHeader(http.StatusOK)
}
// HandleMargeChangePassword changes the account password.
func (s *Server) HandleMargeChangePassword(w http.ResponseWriter, _ *http.Request) {
// Stub implementation
w.WriteHeader(http.StatusOK)
}
// HandleMargeGetEmailAddress returns the account email address.
func (s *Server) HandleMargeGetEmailAddress(w http.ResponseWriter, _ *http.Request) {
resp := models.EmailAddressResponse{
Email: "user@example.com",
}
data, err := xml.MarshalIndent(resp, "", " ")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(xml.Header))
_, _ = w.Write(data)
}
// HandleMargeGetDeviceSettings returns device settings.
func (s *Server) HandleMargeGetDeviceSettings(w http.ResponseWriter, _ *http.Request) {
resp := models.DeviceSettingsResponse{
Settings: []models.DeviceSetting{
{Name: "CLOCK_FORMAT", Value: "24HR"},
},
}
data, err := xml.MarshalIndent(resp, "", " ")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(xml.Header))
_, _ = w.Write(data)
}
// HandleMargeUpdateDeviceSettings updates device settings.
func (s *Server) HandleMargeUpdateDeviceSettings(w http.ResponseWriter, _ *http.Request) {
// Stub implementation
w.WriteHeader(http.StatusOK)
}
// HandleMargeSoftwareUpdate returns the Marge software update information.
func (s *Server) HandleMargeSoftwareUpdate(w http.ResponseWriter, r *http.Request) {
etag := "default-embedded"
@@ -202,11 +281,22 @@ func (s *Server) HandleMargeProviderSettings(w http.ResponseWriter, r *http.Requ
func (s *Server) HandleMargeStreamingToken(w http.ResponseWriter, _ *http.Request) {
// Simple mock token for offline use.
// In a real production environment, this would be a JWT or similar signed token.
// Some speakers might expect a specific format; soundcork uses a distinctive prefix
// Some speakers might expect a specific format; we use a distinctive prefix
// to indicate it's a locally generated token.
token := "soundcork-local-token-" + strconv.FormatInt(time.Now().Unix(), 10)
w.Header().Set("Authorization", "Bearer "+token)
tokenValue := "st-local-token-" + strconv.FormatInt(time.Now().Unix(), 10)
bearerToken := models.NewBearerToken(tokenValue)
data, err := xml.Marshal(bearerToken)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
w.Header().Set("Authorization", bearerToken.GetAuthHeader())
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(xml.Header))
_, _ = w.Write(data)
}
// HandleMargeCustomerSupport handles Marge customer support uploads.
@@ -0,0 +1,102 @@
package handlers
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestMargeStockholmHandlers(t *testing.T) {
r, _ := setupRouter("http://localhost:8001", nil)
ts := httptest.NewServer(r)
defer ts.Close()
t.Run("HandleMargeAccountProfile GET", func(t *testing.T) {
res, err := http.Get(ts.URL + "/customer/account/12345")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), "<accountID>12345</accountID>") {
t.Errorf("Response missing account ID: %s", string(body))
}
})
t.Run("HandleMargeUpdateAccountProfile POST", func(t *testing.T) {
res, err := http.Post(ts.URL+"/customer/account/12345", "application/xml", strings.NewReader("<profile/>"))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
})
t.Run("HandleMargeChangePassword POST", func(t *testing.T) {
res, err := http.Post(ts.URL+"/customer/account/12345/password", "application/xml", strings.NewReader("<password/>"))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
})
t.Run("HandleMargeGetEmailAddress GET", func(t *testing.T) {
res, err := http.Get(ts.URL + "/marge/streaming/account/12345/emailaddress")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), "user@example.com") {
t.Errorf("Response missing email: %s", string(body))
}
})
t.Run("HandleMargeGetDeviceSettings GET", func(t *testing.T) {
res, err := http.Get(ts.URL + "/marge/streaming/device_setting/account/123/device/DEV1/device_settings")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), "CLOCK_FORMAT") {
t.Errorf("Response missing settings: %s", string(body))
}
})
t.Run("HandleMargeUpdateDeviceSettings POST", func(t *testing.T) {
res, err := http.Post(ts.URL+"/marge/streaming/device_setting/account/123/device/DEV1/device_settings", "application/xml", strings.NewReader("<settings/>"))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
})
}
+20 -7
View File
@@ -61,7 +61,7 @@ func TestMargeSoftwareUpdate(t *testing.T) {
}
func TestMargeAccountFull(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
@@ -125,7 +125,7 @@ func TestMargeAccountFull(t *testing.T) {
}
func TestMargePresets(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
@@ -196,7 +196,7 @@ func TestMargePresets(t *testing.T) {
}
func TestMargeUpdatePreset(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
@@ -265,7 +265,7 @@ func TestMargeUpdatePreset(t *testing.T) {
}
func TestMargeDeviceInfo(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
@@ -332,7 +332,7 @@ func TestMargeDeviceInfo(t *testing.T) {
}
func TestMargeAddRemoveDevice(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
@@ -427,7 +427,7 @@ func TestMargePowerOn(t *testing.T) {
}
func TestMargeAdvancedFeatures(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
@@ -471,10 +471,23 @@ func TestMargeAdvancedFeatures(t *testing.T) {
t.Errorf("Expected status OK, got %v", res.Status)
}
contentType := res.Header.Get("Content-Type")
if contentType != "application/vnd.bose.streaming-v1.2+xml" {
t.Errorf("Invalid content type: %s", contentType)
}
token := res.Header.Get("Authorization")
if !strings.HasPrefix(token, "Bearer soundcork-local-token-") {
if !strings.HasPrefix(token, "Bearer st-local-token-") {
t.Errorf("Invalid token header: %s", token)
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), "<bearertoken") {
t.Errorf("Response body missing <bearertoken: %s", body)
}
if !strings.Contains(string(body), token) {
t.Errorf("Response body missing token value: %s", body)
}
})
t.Run("CustomerSupport", func(t *testing.T) {
+4 -4
View File
@@ -14,13 +14,13 @@ var indexHTML []byte
//go:embed web/css/* web/js/*
var webFS embed.FS
//go:embed soundcork/media/*
//go:embed static/media/*
var mediaFS embed.FS
//go:embed soundcork/bmx_services.json
//go:embed static/bmx_services.json
var bmxServicesJSON []byte
//go:embed soundcork/swupdate.xml
//go:embed static/swupdate.xml
var swUpdateXML []byte
// HandleRoot returns the root endpoint response.
@@ -47,7 +47,7 @@ func (s *Server) HandleWeb() http.HandlerFunc {
// HandleMedia returns a handler for serving media files.
func (s *Server) HandleMedia() http.HandlerFunc {
subFS, _ := fs.Sub(mediaFS, "soundcork/media")
subFS, _ := fs.Sub(mediaFS, "static/media")
return func(w http.ResponseWriter, r *http.Request) {
fs := http.StripPrefix("/media/", http.FileServer(http.FS(subFS)))
+3 -3
View File
@@ -35,8 +35,8 @@ func TestRootEndpoint(t *testing.T) {
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), "Bose SoundTouch Toolkit") {
t.Errorf("Expected body to contain 'Bose SoundTouch Toolkit', got %s", string(body))
if !strings.Contains(string(body), "AfterTouch") {
t.Errorf("Expected body to contain 'AfterTouch', got %s", string(body))
}
}
@@ -80,7 +80,7 @@ func TestStaticMedia(t *testing.T) {
ts := httptest.NewServer(r)
defer ts.Close()
// Use a known file from soundcork/media
// Use a known file from static/media
res, err := http.Get(ts.URL + "/media/SiriusXM_Logo_Color.svg")
if err != nil {
t.Fatal(err)
+58 -36
View File
@@ -143,17 +143,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 +171,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 +192,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 +217,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 +530,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 +561,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 +575,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()
+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)
}
}
+37
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/go-chi/chi/v5"
)
// HandleUsageStats handles Marge usage stats uploads.
@@ -49,6 +50,42 @@ func (s *Server) HandleUsageStats(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
// HandleAppEvents handles events from the Bose SoundTouch app (stapp/scmudc).
func (s *Server) HandleAppEvents(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
var req models.DeviceEventsRequest
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid app events format", http.StatusBadRequest)
return
}
deviceID := req.Envelope.UniqueID
if deviceID == "" {
deviceID = chi.URLParam(r, "deviceId")
}
for _, e := range req.Payload.Events {
event := models.DeviceEvent{
Type: e.Type,
Time: e.Time,
MonoTime: req.Envelope.MonoTime,
Data: e.Data,
}
if event.Time == "" {
event.Time = time.Now().Format(time.RFC3339)
}
s.ds.AddDeviceEvent(deviceID, event)
}
w.WriteHeader(http.StatusOK)
}
// HandleErrorStats handles Marge error stats uploads.
func (s *Server) HandleErrorStats(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
+41 -1
View File
@@ -12,7 +12,7 @@ import (
)
func TestStatsHandlers(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatal(err)
}
@@ -63,4 +63,44 @@ func TestStatsHandlers(t *testing.T) {
t.Error("Error stats file was not created")
}
})
t.Run("HandleAppEvents", func(t *testing.T) {
jsonData := `{
"envelope": {
"monoTime": 12345,
"payloadProtocolVersion": "3.1",
"payloadType": "stapp",
"protocolVersion": "1.0",
"time": "2023-10-27T10:00:00Z",
"uniqueId": "device789"
},
"payload": {
"deviceInfo": {
"deviceID": "device789"
},
"events": [
{
"type": "APP_OPEN",
"time": "2023-10-27T10:00:01Z",
"data": {"foo": "bar"}
}
]
}
}`
req := httptest.NewRequest("POST", "/v1/stapp/device789", bytes.NewBufferString(jsonData))
w := httptest.NewRecorder()
s.HandleAppEvents(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status OK, got %d", w.Code)
}
events := ds.GetDeviceEvents("device789")
if len(events) == 0 {
t.Error("App events were not recorded")
} else if events[0].Type != "APP_OPEN" {
t.Errorf("Expected event type APP_OPEN, got %s", events[0].Type)
}
})
}
@@ -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)
+10
View File
@@ -43,6 +43,16 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
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)
r.Post("/account/{account}", server.HandleMargeUpdateAccountProfile)
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
})
// Setup Setup for tests
+31 -29
View File
@@ -15,36 +15,38 @@ 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
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: serverURL,
proxyRedact: proxyRedact,
proxyLogBody: proxyLogBody,
recordEnabled: recordEnabled,
enableSoundcorkProxy: enableSoundcorkProxy,
discoveryInterval: 5 * time.Minute,
}
}
@@ -117,15 +119,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.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before

Width:  |  Height:  |  Size: 418 B

After

Width:  |  Height:  |  Size: 418 B

Before

Width:  |  Height:  |  Size: 681 B

After

Width:  |  Height:  |  Size: 681 B

Before

Width:  |  Height:  |  Size: 859 B

After

Width:  |  Height:  |  Size: 859 B

Before

Width:  |  Height:  |  Size: 246 B

After

Width:  |  Height:  |  Size: 246 B

Before

Width:  |  Height:  |  Size: 381 B

After

Width:  |  Height:  |  Size: 381 B

@@ -1,6 +1,6 @@
# Favicon Meanings
This directory contains favicons for the Soundcork project in various formats (SVG, PNG, ICO). The icons use Morse code and Braille to represent the initials **S** (Sound) and **T** (Touch).
This directory contains favicons for the service in various formats (SVG, PNG, ICO). The icons use Morse code and Braille to represent the initials **S** (Sound) and **T** (Touch).
## Morse Variant (`favicon-morse.*`)
The icon represents the letters **S** and **T** in international Morse code:

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before

Width:  |  Height:  |  Size: 957 B

After

Width:  |  Height:  |  Size: 957 B

Before

Width:  |  Height:  |  Size: 631 B

After

Width:  |  Height:  |  Size: 631 B

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+7
View File
@@ -102,3 +102,10 @@ pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px;
.category-upstream { background-color: #f3e5f5; color: #7b1fa2; }
.status-success { background-color: #e8f5e9; color: #2e7d32; }
.status-error { background-color: #ffebee; color: #c62828; }
.badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 0.8em;
font-weight: bold;
}
+55 -22
View File
@@ -2,12 +2,13 @@
<html>
<head>
<meta charset="UTF-8">
<title>Bose SoundTouch Toolkit</title>
<title>AfterTouch (SoundTouch Toolkit)</title>
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
<link rel="stylesheet" href="/web/css/style.css">
</head>
<body>
<h1>Bose SoundTouch Toolkit</h1>
<h1>AfterTouch</h1>
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
<div class="tabs">
<div class="tab-buttons">
@@ -16,12 +17,12 @@
<button class="tab-btn" onclick="openTab(event, 'tab-devices')">2. Devices</button>
<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</button>
<button class="tab-btn" onclick="openTab(event, 'tab-interactions')">5. Interactions & Events</button>
</div>
<!-- Tab 0: Overview -->
<div id="tab-overview" class="tab-content active">
<h2>Welcome to Bose SoundTouch Toolkit</h2>
<h2>Welcome to AfterTouch</h2>
<p>This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026. It emulates the necessary cloud services locally on your network.</p>
<h3>Migration Process at a Glance</h3>
@@ -86,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>
@@ -104,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>
@@ -160,11 +162,12 @@
<div id="migration-summary" class="summary-box" style="display: none;">
<h3>Migration Summary for <span id="summary-ip"></span></h3>
<p>Migration Status: <span id="migration-status"></span></p>
<p>SSH Connection: <span id="ssh-status"></span></p>
<p id="original-config-status" style="display: none;">Backup: ✅ Found .original config at <code>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code> <button onclick="toggleOriginalConfig()">Show Original Config</button></p>
<p id="no-original-config-status" style="display: none;">Backup: ❌ Not found <button id="backup-config-btn">Backup Config Now</button></p>
<p>Remote Services Enabled: <span id="remote-services-status"></span> <span id="remote-services-found" style="font-size: 0.8em; color: #666;"></span></p>
<p>Local Root CA Trusted: <span id="ca-trust-status"></span> <button id="trust-ca-btn" style="display: none; background-color: #607D8B; color: white; border: none; padding: 2px 8px; font-size: 0.8em; margin-left: 10px;">Trust CA Now</button></p>
<p>AfterTouch Local Root CA Trusted: <span id="ca-trust-status"></span> <button id="trust-ca-btn" style="display: none; background-color: #607D8B; color: white; border: none; padding: 2px 8px; font-size: 0.8em; margin-left: 10px;">Trust CA Now</button></p>
<div id="connection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #eefbff;">
<strong>HTTPS Connection Test:</strong><br>
@@ -213,8 +216,8 @@
<td id="orig-marge">loading...</td>
<td>
<select id="opt-marge" onchange="refreshSummary()">
<option value="soundcork">Soundcork (Go/Python)</option>
<option value="original">Original (Proxy via soundcork-go)</option>
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
@@ -223,8 +226,8 @@
<td id="orig-stats">loading...</td>
<td>
<select id="opt-stats" onchange="refreshSummary()">
<option value="soundcork">Soundcork (Go/Python)</option>
<option value="original">Original (Proxy via soundcork-go)</option>
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
@@ -233,8 +236,8 @@
<td id="orig-sw_update">loading...</td>
<td>
<select id="opt-sw_update" onchange="refreshSummary()">
<option value="soundcork">Soundcork (Go/Python)</option>
<option value="original">Original (Proxy via soundcork-go)</option>
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
@@ -243,8 +246,8 @@
<td id="orig-bmx">loading...</td>
<td>
<select id="opt-bmx" onchange="refreshSummary()">
<option value="soundcork">Soundcork (Go/Python)</option>
<option value="original">Original (Proxy via soundcork-go)</option>
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
@@ -257,14 +260,14 @@
<pre id="current-config"></pre>
</div>
<div id="planned-xml-pane" class="diff-pane">
<span class="config-header">Planned Config (Soundcork)</span>
<span class="config-header">Planned Config (AfterTouch)</span>
<pre id="planned-config"></pre>
</div>
<div id="planned-hosts-pane" class="diff-pane" style="display: none;">
<span class="config-header">Planned /etc/hosts Entries</span>
<pre id="planned-hosts"></pre>
<div style="margin-top: 10px; font-size: 0.9em; color: #666;">
<strong>Note:</strong> This method also injects the local Root CA into <code>/etc/pki/tls/certs/ca-bundle.crt</code> to enable secure HTTPS communication.
<strong>Note:</strong> This method also injects the AfterTouch Local Root CA into <code>/etc/pki/tls/certs/ca-bundle.crt</code> to enable secure HTTPS communication.
</div>
</div>
</div>
@@ -279,15 +282,18 @@
</div>
</div>
<!-- Tab 5: Interactions -->
<!-- Tab 5: Interactions & Events -->
<div id="tab-interactions" class="tab-content">
<h2>Recorded Interactions</h2>
<p>Analysis of traffic handled by this service (self) and proxied to Bose (upstream).</p>
<h2>Recorded Interactions & Device Events</h2>
<p>Analysis of traffic handled by this service (self), proxied to Bose (upstream), and internal device events (telemetry).</p>
<div id="interaction-stats-container" class="summary-box">
<div style="display: flex; gap: 20px; align-items: center; margin-bottom: 15px;">
<p style="margin: 0;">Total Requests: <strong id="total-requests">0</strong></p>
<button onclick="fetchInteractionStats()">Refresh Stats</button>
<div style="margin-left: 10px;">
<button onclick="showDeviceEvents()">View App/Device Events</button>
</div>
<div style="margin-left: auto; text-align: right;">
<button onclick="cleanupSessions()" class="btn-danger">Cleanup old sessions</button>
<div style="font-size: 0.75em; color: #666; margin-top: 3px;">Keeps only the 10 most recent sessions</div>
@@ -358,12 +364,39 @@
</div>
<pre id="interaction-content" style="white-space: pre-wrap; font-family: 'Courier New', Courier, monospace; font-size: 0.9em; margin: 0; padding: 10px; overflow-x: auto; max-height: 600px;"></pre>
</div>
<!-- Device Events Overlay -->
<div id="device-events-overlay" class="summary-box" style="margin-top: 20px; display: none; background: #fdfdfd; border: 1px solid #ddd;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0;">App & Device Events</h3>
<div>
<select id="event-device-selector" onchange="fetchDeviceEvents(this.value)">
<option value="">-- Select Device --</option>
</select>
<button onclick="document.getElementById('device-events-overlay').style.display='none'" style="margin-left: 10px;">Close</button>
</div>
</div>
<div id="events-list-container" style="max-height: 400px; 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;">Type</th>
<th style="padding: 8px;">Data</th>
</tr>
</thead>
<tbody id="events-list">
<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Select a device to view events.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script src="/web/js/script.js"></script>
<footer style="margin-top: 50px; padding: 20px; border-top: 1px solid #eee; font-size: 0.8em; color: #888; text-align: center;">
<span id="version-info">SoundTouch Toolkit</span>
<span id="version-info">AfterTouch</span>
</footer>
</body>
</html>
+96 -8
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...';
@@ -97,8 +105,12 @@ async function fetchDevices() {
// Clear and repopulate selectors
const currentSyncVal = syncSelector.value;
const currentMigrationVal = migrationSelector.value;
const eventSelector = document.getElementById('event-device-selector');
const currentEventVal = eventSelector ? eventSelector.value : "";
syncSelector.innerHTML = '<option value="">-- Select a device --</option>';
migrationSelector.innerHTML = '<option value="">-- Select a device --</option>';
if (eventSelector) eventSelector.innerHTML = '<option value="">-- Select a device --</option>';
devices.forEach(d => {
const methodLabel = d.discovery_method === 'manual' ? '👤 Manual' : '🔍 Auto';
@@ -126,12 +138,20 @@ async function fetchDevices() {
optMigrate.value = d.ip_address;
optMigrate.textContent = `${d.name} (${d.ip_address})`;
migrationSelector.appendChild(optMigrate);
if (eventSelector) {
const optEvent = document.createElement('option');
optEvent.value = d.device_id || d.ip_address;
optEvent.textContent = `${d.name} (${d.ip_address})`;
eventSelector.appendChild(optEvent);
}
});
html += '</table>';
container.innerHTML = html;
if (currentSyncVal) syncSelector.value = currentSyncVal;
if (currentMigrationVal) migrationSelector.value = currentMigrationVal;
if (eventSelector && currentEventVal) eventSelector.value = currentEventVal;
// Asynchronously fetch live info for each device
devices.forEach(d => updateDeviceInfo(d.ip_address));
@@ -227,7 +247,7 @@ async function fetchVersion() {
const data = await response.json();
const info = document.getElementById('version-info');
if (info && data.version) {
info.innerText = `SoundTouch Toolkit ${data.version} (${data.commit}) - ${data.date}`;
info.innerText = `AfterTouch ${data.version} (${data.commit}) - ${data.date}`;
}
} catch (error) {
console.error('Failed to fetch version info', error);
@@ -478,6 +498,59 @@ async function viewInteraction(file) {
}
}
async function showDeviceEvents() {
const overlay = document.getElementById('device-events-overlay');
overlay.style.display = 'block';
overlay.scrollIntoView({ behavior: 'smooth' });
// Ensure device selector is populated (handled by fetchDevices)
// but if it's still empty, we can try to trigger a fetch
const selector = document.getElementById('event-device-selector');
if (selector.options.length <= 1) {
fetchDevices();
}
}
async function fetchDeviceEvents(deviceId) {
if (!deviceId) return;
const list = document.getElementById('events-list');
list.innerHTML = '<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Loading events...</td></tr>';
try {
const response = await fetch(`/setup/devices/${deviceId}/events`);
const data = await response.json();
const events = data.events;
list.innerHTML = '';
if (!events || events.length === 0) {
list.innerHTML = '<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">No events found for this device.</td></tr>';
return;
}
// Sort events by time descending
events.sort((a, b) => (b.time || "").localeCompare(a.time || ""));
events.forEach(e => {
const tr = document.createElement('tr');
tr.style.borderBottom = '1px solid #eee';
const time = e.time || "";
const type = e.type || "";
const data = JSON.stringify(e.data || {});
tr.innerHTML = `
<td style="padding: 8px; font-size: 0.8em; white-space: nowrap;">${time}</td>
<td style="padding: 8px;"><span class="badge category-self">${type}</span></td>
<td style="padding: 8px; font-size: 0.85em; font-family: monospace; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title='${data}'>${data}</td>
`;
list.appendChild(tr);
});
} catch (error) {
list.innerHTML = `<tr><td colspan="3" style="padding: 20px; text-align: center; color: #f44336;">Error loading events: ${error.message}</td></tr>`;
}
}
document.addEventListener('DOMContentLoaded', () => {
fetchSettings();
fetchDevices();
@@ -603,7 +676,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,
@@ -662,6 +735,11 @@ async function showSummary(ip) {
document.getElementById('ssh-status').innerText = summary.ssh_success ? '✅ Success' : '❌ Failed';
document.getElementById('ssh-status').style.color = summary.ssh_success ? 'green' : 'red';
const migrationStatus = document.getElementById('migration-status');
migrationStatus.innerText = summary.is_migrated ? '✅ Migrated to AfterTouch' : '❌ Not Migrated';
migrationStatus.style.color = summary.is_migrated ? 'green' : 'red';
migrationStatus.style.fontWeight = 'bold';
document.getElementById('original-config-status').style.display = summary.original_config ? 'block' : 'none';
document.getElementById('no-original-config-status').style.display = summary.original_config ? 'none' : 'block';
document.getElementById('original-config-content').innerText = summary.original_config || '';
@@ -737,6 +815,7 @@ async function showSummary(ip) {
const rebootBtn = document.getElementById('reboot-speaker-btn');
rebootBtn.onclick = () => reboot(ip);
rebootBtn.disabled = !summary.ssh_success;
rebootBtn.style.border = 'none'; // Reset border if it was set during migration
const remoteBtn = document.getElementById('ensure-remote-btn');
remoteBtn.onclick = () => ensureRemoteServices(ip);
@@ -847,7 +926,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 = {
@@ -876,7 +955,16 @@ async function migrate(ip) {
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully started migration for ' + ip + '.';
statusDiv.innerHTML = 'Successfully started migration for ' + ip + '. <strong>Please reboot the device to activate the changes.</strong>';
// Make reboot button available and prominent
const rebootBtn = document.getElementById('reboot-speaker-btn');
rebootBtn.style.display = 'inline-block';
rebootBtn.disabled = false;
rebootBtn.style.border = '2px solid #000';
// Re-show summary but with prominence on reboot
summaryDiv.style.display = 'block';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Migration failed for ' + ip + ': ' + (result.message || 'Unknown error');
+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
+103 -9
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
@@ -25,6 +26,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 +62,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 +104,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) {
@@ -141,6 +234,7 @@ func (r *Recorder) writeRequest(buf *bytes.Buffer, req *http.Request, replacemen
}
fmt.Fprintf(buf, "%s %s\n", req.Method, displayURL)
fmt.Fprintf(buf, "Host: %s\n", req.Host)
for k, vv := range req.Header {
if r.Redact && isSensitive(k) {
+16
View File
@@ -15,6 +15,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)
@@ -93,12 +94,19 @@ func TestRecorder_Record_Structure(t *testing.T) {
if len(f.Name()) < 5 || !isDigit(f.Name()[0]) || !isDigit(f.Name()[1]) || !isDigit(f.Name()[2]) || !isDigit(f.Name()[3]) || f.Name()[4] != '-' {
t.Errorf("Filename %s does not have correct 0000- prefix", f.Name())
}
// Verify Host header is present
content, _ := os.ReadFile(filepath.Join(expectedDir, f.Name()))
if !strings.Contains(string(content), "Host: ") {
t.Errorf("Recorded file does not contain Host header:\n%s", string(content))
}
}
})
}
}
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)
@@ -157,6 +165,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)
@@ -211,6 +220,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)
@@ -260,6 +270,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)
@@ -302,6 +313,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)
@@ -344,6 +356,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)
@@ -389,6 +402,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)
@@ -614,6 +628,7 @@ func TestRecorder_GetInteractionContent(t *testing.T) {
}
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)
@@ -667,6 +682,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)
+108 -13
View File
@@ -64,6 +64,7 @@ type MigrationSummary struct {
FirmwareVersion string `json:"firmware_version,omitempty"`
CACertTrusted bool `json:"ca_cert_trusted"`
ServerHTTPSURL string `json:"server_https_url,omitempty"`
IsMigrated bool `json:"is_migrated"`
}
// SSHClient defines the interface for SSH operations.
@@ -72,7 +73,7 @@ type SSHClient interface {
UploadContent(content []byte, remotePath string) error
}
// Manager handles the migration of speakers to the soundcork service.
// Manager handles the migration of speakers to the service.
type Manager struct {
ServerURL string
DataStore *datastore.DataStore
@@ -214,6 +215,9 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
"stats.bose.com",
"bmx.bose.com",
"content.api.bose.io",
"events.api.bosecm.com",
"bose-prod.apigee.net",
"worldwide.bose.com",
}
var hostsLines []string
@@ -245,9 +249,60 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
}
}
// 6. Check if migrated
m.checkIsMigrated(summary, deviceIP)
return summary, nil
}
// checkIsMigrated determines if the device is already migrated to AfterTouch.
func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
if !summary.SSHSuccess {
return
}
// Case 1: XML Migration
// Check if any URL in the current config points to our server (targetURL)
if summary.ParsedCurrentConfig != nil {
targetURL := m.ServerURL
// Strip protocol for comparison if needed, or just check for substring
parsedTarget, err := url.Parse(targetURL)
if err == nil {
targetHost := parsedTarget.Hostname()
if strings.Contains(summary.ParsedCurrentConfig.MargeServerUrl, targetHost) ||
strings.Contains(summary.ParsedCurrentConfig.StatsServerUrl, targetHost) ||
strings.Contains(summary.ParsedCurrentConfig.SwUpdateUrl, targetHost) ||
strings.Contains(summary.ParsedCurrentConfig.BmxRegistryUrl, targetHost) {
summary.IsMigrated = true
return
}
}
}
// Case 2: /etc/hosts + Trust CA Migration
// Check if /etc/hosts contains redirections for Bose domains
client := m.NewSSH(deviceIP)
hostsContent, err := client.Run("cat /etc/hosts")
if err == nil {
boseDomains := []string{
"streaming.bose.com",
"updates.bose.com",
"stats.bose.com",
"bmx.bose.com",
}
for _, domain := range boseDomains {
if strings.Contains(hostsContent, domain) {
// If CA is also trusted, it's a strong indicator of migration
if summary.CACertTrusted {
summary.IsMigrated = true
return
}
}
}
}
}
// populateDeviceInfo fills in device information from datastore and live info
func (m *Manager) populateDeviceInfo(summary *MigrationSummary, deviceIP string) {
// Populate from datastore if available
@@ -354,19 +409,19 @@ func (m *Manager) applyProxyOptions(plannedCfg *PrivateCfg, proxyURL string, opt
return
}
if options["marge"] == "original" && currentCfg.MargeServerUrl != "" {
if options["marge"] == "upstream" && currentCfg.MargeServerUrl != "" {
plannedCfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl)
}
if options["stats"] == "original" && currentCfg.StatsServerUrl != "" {
if options["stats"] == "upstream" && currentCfg.StatsServerUrl != "" {
plannedCfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl)
}
if options["sw_update"] == "original" && currentCfg.SwUpdateUrl != "" {
if options["sw_update"] == "upstream" && currentCfg.SwUpdateUrl != "" {
plannedCfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl)
}
if options["bmx"] == "original" && currentCfg.BmxRegistryUrl != "" {
if options["bmx"] == "upstream" && currentCfg.BmxRegistryUrl != "" {
plannedCfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl)
}
}
@@ -437,7 +492,7 @@ func (m *Manager) checkCACertTrusted(summary *MigrationSummary, deviceIP string)
}
}
// MigrateSpeaker configures the speaker at the given IP to use this soundcork service.
// MigrateSpeaker configures the speaker at the given IP to use this service.
func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options map[string]string, method MigrationMethod) (string, error) {
if targetURL == "" {
targetURL = m.ServerURL
@@ -806,6 +861,9 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
"stats.bose.com",
"bmx.bose.com",
"content.api.bose.io",
"events.api.bosecm.com",
"bose-prod.apigee.net",
"worldwide.bose.com",
}
hostsContent, err := client.Run("cat /etc/hosts")
@@ -815,16 +873,53 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
return logs, fmt.Errorf("failed to read /etc/hosts: %w", err)
}
for _, domain := range domains {
if !strings.Contains(hostsContent, domain) {
entry := fmt.Sprintf("%s\t%s", hostIP, domain)
lines := strings.Split(hostsContent, "\n")
if hostsContent != "" && !strings.HasSuffix(hostsContent, "\n") {
hostsContent += "\n"
var newLines []string
domainFound := make(map[string]bool)
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
newLines = append(newLines, line)
continue
}
fields := strings.Fields(trimmed)
if len(fields) >= 2 {
domain := fields[1]
isBoseDomain := false
for _, d := range domains {
if d == domain {
isBoseDomain = true
break
}
}
hostsContent += entry + "\n"
if isBoseDomain {
// Update existing entry with new IP
newLines = append(newLines, fmt.Sprintf("%s\t%s", hostIP, domain))
domainFound[domain] = true
continue
}
}
newLines = append(newLines, line)
}
// Add missing domains
for _, domain := range domains {
if !domainFound[domain] {
newLines = append(newLines, fmt.Sprintf("%s\t%s", hostIP, domain))
}
}
hostsContent = strings.Join(newLines, "\n")
if !strings.HasSuffix(hostsContent, "\n") {
hostsContent += "\n"
}
// 3. Upload new /etc/hosts
@@ -1000,7 +1095,7 @@ func (m *Manager) Reboot(deviceIP string) (string, error) {
const TestDomain = "custom-test-api.bose.fake"
// CALabel is the label used to identify the local CA certificate in the trust store.
const CALabel = "# Soundcork Local Root CA"
const CALabel = "# AfterTouch"
// TestHostsRedirection performs a preliminary check to see if /etc/hosts redirection works.
func (m *Manager) TestHostsRedirection(deviceIP, targetURL string) (string, error) {
+121 -5
View File
@@ -109,6 +109,60 @@ func TestMigrateViaHosts(t *testing.T) {
}
}
func TestMigrateViaHosts_UpdateExisting(t *testing.T) {
tempDir, err := os.MkdirTemp("", "setup-test-update")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
_ = cm.EnsureCA()
m := NewManager("http://192.168.1.100:8000", nil, cm)
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
if command == "cat /etc/hosts" {
return "127.0.0.1 localhost\n1.2.3.4\tstreaming.bose.com\n1.2.3.4\tupdates.bose.com", nil
}
if strings.HasPrefix(command, "[ -f") {
return "", nil // Backup already exists
}
if strings.HasPrefix(command, "grep -F") {
return "matched", nil // CA already trusted
}
return "", nil
},
uploadContentFunc: func(content []byte, remotePath string) error {
if remotePath == "/etc/hosts" {
c := string(content)
if !strings.Contains(c, "192.168.1.100\tstreaming.bose.com") {
t.Errorf("Expected updated IP for streaming.bose.com, got:\n%s", c)
}
if !strings.Contains(c, "192.168.1.100\tupdates.bose.com") {
t.Errorf("Expected updated IP for updates.bose.com, got:\n%s", c)
}
if !strings.Contains(c, "192.168.1.100\tevents.api.bosecm.com") {
t.Errorf("Expected new domain events.api.bosecm.com, got:\n%s", c)
}
// Ensure no duplicates
if strings.Count(c, "streaming.bose.com") != 1 {
t.Errorf("Expected streaming.bose.com to appear exactly once, got %d", strings.Count(c, "streaming.bose.com"))
}
}
return nil
},
}
}
_, err = m.migrateViaHosts("192.168.1.10", "http://192.168.1.100:8000")
if err != nil {
t.Fatalf("migrateViaHosts failed: %v", err)
}
}
func TestGetLiveDeviceInfo(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/info" {
@@ -187,7 +241,7 @@ func TestGetMigrationSummary_WithProxyOptions(t *testing.T) {
defer server.Close()
host := server.Listener.Addr().String()
manager := NewManager("http://soundcork:8000", nil, nil)
manager := NewManager("http://st-service:8000", nil, nil)
// Since we can't easily mock SSH here without a full SSH server,
// we are testing the logic that depends on ParsedCurrentConfig being nil or not.
@@ -195,10 +249,10 @@ func TestGetMigrationSummary_WithProxyOptions(t *testing.T) {
// If SSH fails, ParsedCurrentConfig will be nil.
options := map[string]string{
"marge": "original",
"stats": "soundcork",
"sw_update": "original",
"bmx": "soundcork",
"marge": "upstream",
"stats": "self",
"sw_update": "upstream",
"bmx": "self",
}
summary, err := manager.GetMigrationSummary(host, "http://target:8000", "http://proxy:8000", options)
@@ -858,3 +912,65 @@ func TestMigrateSpeaker_PreFlightFailure(t *testing.T) {
func contains(s, substr string) bool {
return strings.Contains(s, substr)
}
func TestCheckIsMigrated(t *testing.T) {
m := NewManager("http://aftertouch:8000", nil, nil)
t.Run("XML Migrated", func(t *testing.T) {
summary := &MigrationSummary{
SSHSuccess: true,
ParsedCurrentConfig: &PrivateCfg{
MargeServerUrl: "http://aftertouch:8000/marge",
},
}
m.checkIsMigrated(summary, "127.0.0.1")
if !summary.IsMigrated {
t.Errorf("Expected IsMigrated to be true for XML migration")
}
})
t.Run("Hosts Migrated", func(t *testing.T) {
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
if command == "cat /etc/hosts" {
return "127.0.0.1\tstreaming.bose.com", nil
}
return "", nil
},
}
}
summary := &MigrationSummary{
SSHSuccess: true,
CACertTrusted: true,
}
m.checkIsMigrated(summary, "127.0.0.1")
if !summary.IsMigrated {
t.Errorf("Expected IsMigrated to be true for hosts migration")
}
})
t.Run("Not Migrated", func(t *testing.T) {
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
if command == "cat /etc/hosts" {
return "127.0.0.1\tlocalhost", nil
}
return "", nil
},
}
}
summary := &MigrationSummary{
SSHSuccess: true,
ParsedCurrentConfig: &PrivateCfg{
MargeServerUrl: "http://streaming.bose.com/marge",
},
CACertTrusted: false,
}
m.checkIsMigrated(summary, "127.0.0.1")
if summary.IsMigrated {
t.Errorf("Expected IsMigrated to be false for non-migrated device")
}
})
}
+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")"