Compare commits
@@ -171,6 +171,7 @@ test-http-client:
|
||||
/workdir/get_full_account.http \
|
||||
/workdir/create_group.http \
|
||||
/workdir/get_group.http \
|
||||
/workdir/rename_device.http \
|
||||
/workdir/unregister_device.http \
|
||||
--report; \
|
||||
EXIT_CODE=$$?; \
|
||||
|
||||
@@ -404,7 +404,7 @@ func setupWiFiPushCmd() *cli.Command {
|
||||
&cli.StringFlag{Name: "pass", Required: true, Usage: "Home Wi-Fi password"},
|
||||
&cli.StringFlag{Name: "security", Value: setup.DefaultWiFiSecurity, Usage: "Security type (wpa_or_wpa2, wep, open)"},
|
||||
&cli.StringFlag{Name: "ap-host", Value: setup.SpeakerSetupAP, Usage: "Speaker's setup-mode IP"},
|
||||
&cli.DurationFlag{Name: "request-timeout", Value: 10 * time.Second},
|
||||
&cli.DurationFlag{Name: "request-timeout", Value: 30 * time.Second, Usage: "Per-request timeout (the speaker can be slow to ACK before tearing down AP mode; 10 s often races)"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
params := setup.PushWiFiCredentialsParams{
|
||||
@@ -949,6 +949,17 @@ func renderMigrationSummary(deviceIP, serviceURL string, s *setup.MigrationSumma
|
||||
if s.ResolveIPError != "" {
|
||||
PrintError("Resolve IP error: " + s.ResolveIPError)
|
||||
}
|
||||
|
||||
// Observability for the IP-resolve path. Source tells the user whether
|
||||
// the speaker itself was consulted (authoritative) or only the service
|
||||
// (best-effort). DurationMS lets us watch the SSH-ping cost trend in
|
||||
// the wild — historical comment claimed 2-5 s on firmware 27, worth
|
||||
// re-evaluating as data accumulates.
|
||||
if s.ResolveIPSource != "" {
|
||||
fmt.Printf("Resolve IP source : %s (%d ms)\n", s.ResolveIPSource, s.ResolveIPDurationMS)
|
||||
} else if s.ResolveIPDurationMS > 0 {
|
||||
fmt.Printf("Resolve IP : %d ms\n", s.ResolveIPDurationMS)
|
||||
}
|
||||
}
|
||||
|
||||
func setupRebootCmd() *cli.Command {
|
||||
@@ -1353,10 +1364,11 @@ func setupPairCmd() *cli.Command {
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "account", Usage: "7-digit account ID (empty = generate)"},
|
||||
&cli.StringFlag{Name: "mode", Value: "full", Usage: "full (state machine) or bare (setMargeAccount only — experimental)"},
|
||||
&cli.StringFlag{Name: "service-url", Value: "http://aftertouch.local:8000", Usage: "AfterTouch base URL (used by mode=full for defaults)"},
|
||||
&cli.StringFlag{Name: "service-url", Value: "http://aftertouch.local:8000", Usage: "AfterTouch base URL (also populates <boseServer>/<updateServer> in setMargeAccount)"},
|
||||
&cli.StringFlag{Name: "name", Usage: "Speaker name to set during pairing (empty = keep current)"},
|
||||
&cli.IntFlag{Name: "language", Value: setup.LanguageEnglish, Usage: "sysLanguage code (2 = English)"},
|
||||
&cli.DurationFlag{Name: "step-timeout", Value: 8 * time.Second},
|
||||
&cli.StringFlag{Name: "token", Usage: "userAuthToken value (empty = use built-in placeholder matching the Bose app token shape)"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
cfg := GetClientConfig(c)
|
||||
@@ -1401,8 +1413,20 @@ func runPairBare(c *cli.Context, deviceIP, accountID string) error {
|
||||
fmt.Printf("pre /info deviceID=%s margeAccountUUID=%q margeURL=%q\n",
|
||||
info.DeviceID, info.MargeAccountUUID, info.MargeURL)
|
||||
|
||||
// Service URL drives the extended <PairDeviceWithAccount> payload
|
||||
// (boseServer/updateServer/accountEmail). When empty, the session
|
||||
// falls back to the minimal historical shape (accountId +
|
||||
// userAuthToken only).
|
||||
serviceURL := c.String("service-url")
|
||||
|
||||
var extras setup.MargePairingExtras
|
||||
if serviceURL != "" {
|
||||
extras = setup.MargePairingExtras{BoseServer: serviceURL}
|
||||
}
|
||||
|
||||
session, err := setup.DialSession(deviceIP, info.DeviceID, setup.SessionConfig{
|
||||
StepTimeout: c.Duration("step-timeout"),
|
||||
StepTimeout: c.Duration("step-timeout"),
|
||||
PairingExtras: extras,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial WS: %w", err)
|
||||
@@ -1413,9 +1437,13 @@ func runPairBare(c *cli.Context, deviceIP, accountID string) error {
|
||||
ctx, cancel := context.WithTimeout(c.Context, c.Duration("step-timeout")+2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fmt.Printf("→ setMargeAccount accountID=%s (no SETUP bracket)\n", accountID)
|
||||
if serviceURL != "" {
|
||||
fmt.Printf("→ setMargeAccount accountID=%s (extended: boseServer=%s)\n", accountID, serviceURL)
|
||||
} else {
|
||||
fmt.Printf("→ setMargeAccount accountID=%s (minimal payload, no SETUP bracket)\n", accountID)
|
||||
}
|
||||
|
||||
if pairErr := session.SetMargeAccount(ctx, accountID, ""); pairErr != nil {
|
||||
if pairErr := session.SetMargeAccount(ctx, accountID, c.String("token")); pairErr != nil {
|
||||
PrintError(fmt.Sprintf("setMargeAccount: %v", pairErr))
|
||||
return pairErr
|
||||
}
|
||||
|
||||
@@ -484,6 +484,8 @@ func main() {
|
||||
}
|
||||
|
||||
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
|
||||
|
||||
runHTTPSPreflight(config.httpsServerURL, config.serverURL, config.dnsEnabled, server.ResolveServerURLIPForPreflight)
|
||||
}()
|
||||
|
||||
return http.ListenAndServe(config.addr, r)
|
||||
@@ -906,11 +908,17 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Post("/v1/favorite/{stationID}", server.HandleTuneInFavorite)
|
||||
r.Delete("/v1/favorite/{stationID}", server.HandleTuneInDeleteFavorite)
|
||||
})
|
||||
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken)
|
||||
})
|
||||
|
||||
// Orion (LOCAL_INTERNET_RADIO) lives at the top level — the BMX registry
|
||||
// advertises baseUrl `{BMX_SERVER}/core02/svc-bmx-adapter-orion/prod/orion`
|
||||
// (no `/bmx/` prefix; verified against the upstream capture in
|
||||
// pkg/service/handlers/static/bmx_services_ustream.json), so speakers
|
||||
// reach the token + station endpoints at exactly these paths under
|
||||
// either DNS-interception or URL-flip migration.
|
||||
r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken)
|
||||
r.Get("/core02/svc-bmx-adapter-orion/prod/orion/station", server.HandleOrionPlayback)
|
||||
|
||||
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
|
||||
|
||||
r.Route("/streaming", func(r chi.Router) {
|
||||
@@ -928,24 +936,38 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/presets/all", server.HandleMargeAccountPresets)
|
||||
r.Get("/provider_settings", server.HandleMargeProviderSettings)
|
||||
|
||||
// All `/device` routes share one chi subrouter. Two
|
||||
// overlapping subrouters (`/device` + `/device/{device}`)
|
||||
// caused chi's radix-tree resolver to bind a runtime
|
||||
// request to the more-specific prefix even when only the
|
||||
// less-specific subrouter had a matching method handler,
|
||||
// producing the [UNHANDLED] → upstream-proxy fall-through
|
||||
// behind issue #285's first-attempted fix. One subrouter
|
||||
// keeps every device-scoped path resolvable; see
|
||||
// TestPUTRenameRoutesToLocalHandler for the regression
|
||||
// against the production router.
|
||||
r.Route("/device", func(r chi.Router) {
|
||||
r.Post("/", server.HandleMargeAddDevice)
|
||||
r.Post("/{device}", server.HandleMargeAddDevice)
|
||||
})
|
||||
// PUT is the rename / update path — speakers fire
|
||||
// this against PUT /streaming/account/{a}/device/{d}
|
||||
// when the user renames via Bose App or
|
||||
// `soundtouch-cli name set`. Issue #285.
|
||||
r.Put("/{device}", server.HandleMargeUpdateDevice)
|
||||
r.Delete("/{device}", server.HandleMargeRemoveDevice)
|
||||
|
||||
r.Route("/device/{device}", func(r chi.Router) {
|
||||
r.Get("/presets", server.HandleMargePresets)
|
||||
r.Post("/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Put("/preset/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Delete("/preset/{presetNumber}", server.HandleMargeRemovePreset)
|
||||
r.Get("/recent", server.HandleMargeRecents)
|
||||
r.Get("/recents", server.HandleMargeRecents)
|
||||
r.Post("/recent", server.HandleMargeAddRecent)
|
||||
r.Get("/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Put("/{device}/preset/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Delete("/{device}/preset/{presetNumber}", server.HandleMargeRemovePreset)
|
||||
r.Get("/{device}/recent", server.HandleMargeRecents)
|
||||
r.Get("/{device}/recents", server.HandleMargeRecents)
|
||||
r.Post("/{device}/recent", server.HandleMargeAddRecent)
|
||||
|
||||
r.Get("/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/group/member", server.HandleMargeDeviceGroupMember)
|
||||
r.Get("/{device}/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
})
|
||||
|
||||
// Speakers POST to /group/ (with trailing slash) when forwarding
|
||||
@@ -955,8 +977,6 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Post("/group/", server.HandleMargeAddGroup)
|
||||
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
|
||||
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
|
||||
r.Delete("/device/{device}", server.HandleMargeRemoveDevice)
|
||||
})
|
||||
|
||||
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
@@ -1184,6 +1204,45 @@ func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, h
|
||||
}()
|
||||
}
|
||||
|
||||
// runHTTPSPreflight checks whether speakers' implicit :443 target reaches
|
||||
// AfterTouch. Runs after the HTTPS listener has had a moment to come up; if
|
||||
// the listener is already on :443 the check is skipped. Emits a single WARN
|
||||
// log line with actionable guidance when either probe fails.
|
||||
//
|
||||
// Only runs when dnsEnabled is true: the :443 reachability only matters when
|
||||
// speakers are reaching AfterTouch via intercepted Bose hostnames (i.e. the
|
||||
// DNS migration method). For direct SDK-override migration the speaker
|
||||
// connects to the configured https-port directly, so :443 is irrelevant.
|
||||
// Users with external DNS interception (Pi-hole, router rules) can still see
|
||||
// the live result on /setup/settings even when this startup warn is silent.
|
||||
func runHTTPSPreflight(httpsServerURL, serverURL string, dnsEnabled bool, resolver func(string) (string, error)) {
|
||||
if !dnsEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
port := handlers.PortFromHTTPSServerURL(httpsServerURL)
|
||||
if port == 0 {
|
||||
// Can't determine the listener port — be silent rather than misleading.
|
||||
return
|
||||
}
|
||||
|
||||
// Give the listener a head start so a successful bind beats the probe.
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
res := handlers.Check443Reachability(port, serverURL, resolver, handlers.ProbeDialTimeoutStartup)
|
||||
|
||||
guidance := handlers.FormatPreflightGuidance(port, res)
|
||||
if guidance == "" {
|
||||
if !res.Skipped {
|
||||
log.Printf("HTTPS pre-flight: :443 reachable at localhost and %s ✓", res.LANHost)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Print(guidance)
|
||||
}
|
||||
|
||||
// matchesDomain checks if a certificate domain (which may be a wildcard) matches a server name
|
||||
func matchesDomain(certDomain, serverName string) bool {
|
||||
if certDomain == serverName {
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -102,3 +104,56 @@ func TestPrintRoutes(t *testing.T) {
|
||||
t.Errorf("Router routes changed! Diff the snapshot at %s with %s", snapshotPath, actualPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPUTRenameRoutesToLocalHandler reproduces the runtime routing
|
||||
// behaviour the user saw on their deployed v0.80.0: a PUT to
|
||||
// /streaming/account/{a}/device/{d} should land on
|
||||
// HandleMargeUpdateDevice, not fall through to the [UNHANDLED]
|
||||
// proxy. The handlers-package test (TestIssue285_*) uses a simplified
|
||||
// router that doesn't have the overlapping `/device` and
|
||||
// `/device/{device}` route groups, so it can't catch a chi radix-
|
||||
// tree resolution that prefers the more-specific subrouter.
|
||||
//
|
||||
// This test exercises the actual production setupRouter so a
|
||||
// regression in the route topology is caught against the same chi
|
||||
// behaviour speakers will see.
|
||||
func TestPUTRenameRoutesToLocalHandler(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "router-rename-")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir temp: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := handlers.NewServer(ds, nil, "http://localhost:8000", false, false, false)
|
||||
r := setupRouter(server)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
body := `<?xml version="1.0" encoding="UTF-8" ?><device deviceid="A81B6A536A98"><name>Sound Machinechen</name><macaddress>A81B6A536A98</macaddress></device>`
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
ts.URL+"/streaming/account/1111111/device/A81B6A536A98",
|
||||
strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("PUT: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// 200 means our local HandleMargeUpdateDevice handled it.
|
||||
// 401 / 502 / anything else means the request fell through to
|
||||
// the [UNHANDLED] proxy and got the upstream response — which
|
||||
// is exactly the failure mode #285 was supposed to fix.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, want 200 (local handler). Anything else means the request fell through to [UNHANDLED] proxy — chi is routing to a different subrouter than the PUT registration intended.", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ DELETE /setup/dns-discoveries handlers.(
|
||||
DELETE /setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
|
||||
DELETE /setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm
|
||||
DELETE /setup/parity-mismatches handlers.(*Server).HandleClearParityMismatches-fm
|
||||
DELETE /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
|
||||
DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
|
||||
DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
GET / handlers.(*Server).HandleRoot-fm
|
||||
@@ -30,6 +31,7 @@ GET /bmx/tunein/v1/playback/episodes/{podcastID} handlers.(
|
||||
GET /bmx/tunein/v1/playback/station/{stationID} handlers.(*Server).HandleTuneInPlayback-fm
|
||||
GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm
|
||||
GET /ced/* handlers.(*Server).HandleCedStatic
|
||||
GET /core02/svc-bmx-adapter-orion/prod/orion/station handlers.(*Server).HandleOrionPlayback-fm
|
||||
GET /custom/v1/playback/{encodedURL} handlers.(*Server).HandleCustomPlayback-fm
|
||||
GET /customer/account/{account} handlers.(*Server).HandleMargeAccountProfile-fm
|
||||
GET /docs/* handlers.(*Server).HandleDocs-fm
|
||||
@@ -97,11 +99,10 @@ POST /accounts/{account}/group handlers.(
|
||||
POST /accounts/{account}/group/ handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
|
||||
POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm
|
||||
POST /bmx/core02/svc-bmx-adapter-orion/prod/orion/token handlers.(*Server).HandleOrionToken-fm
|
||||
POST /bmx/orion/v1/playback/station/{data} handlers.(*Server).HandleOrionPlayback-fm
|
||||
POST /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInFavorite-fm
|
||||
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
|
||||
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm
|
||||
POST /core02/svc-bmx-adapter-orion/prod/orion/token handlers.(*Server).HandleOrionToken-fm
|
||||
POST /customer/account/{account} handlers.(*Server).HandleMargeUpdateAccountProfile-fm
|
||||
POST /customer/account/{account}/password handlers.(*Server).HandleMargeChangePassword-fm
|
||||
POST /mgmt/accounts/{accountId}/language handlers.(*Server).HandleMgmtUpdateAccountLanguage-fm
|
||||
@@ -155,5 +156,6 @@ POST /streaming/support/power_on handlers.(
|
||||
POST /v1/scmudc/{deviceId} handlers.(*Server).HandleAppEvents-fm
|
||||
POST /v1/stapp/{deviceId} handlers.(*Server).HandleAppEvents-fm
|
||||
PUT /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
PUT /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeUpdateDevice-fm
|
||||
PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
TRACE /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
|
||||
@@ -48,6 +48,12 @@ logread -f | grep -Ei '(marge|preset)'
|
||||
```
|
||||
This is particularly useful for debugging preset synchronization and service redirection issues.
|
||||
|
||||
For HTTPS / connection-refused debugging (e.g. `Curl 7, http 0`), drop the speaker's loopback chatter so only outbound calls remain visible:
|
||||
```bash
|
||||
logread -f | grep -v '127.0.0.1'
|
||||
```
|
||||
The speaker generates a steady stream of localhost-to-localhost HTTP traffic between its internal services; filtering it out makes the actual cloud / AfterTouch attempts (the ones that matter when diagnosing redirect or TLS issues) easy to read in real time.
|
||||
|
||||
---
|
||||
|
||||
## 2. Traffic Logging & Interception
|
||||
|
||||
@@ -35,7 +35,7 @@ soundtouch-cli --host 192.168.1.100 preset store \
|
||||
soundtouch-cli --host 192.168.1.100 preset store \
|
||||
--slot 3 \
|
||||
--source TUNEIN \
|
||||
--location "/v1/playbook/station/s33828" \
|
||||
--location "/v1/playback/station/s33828" \
|
||||
--name "K-LOVE Radio"
|
||||
```
|
||||
|
||||
@@ -103,7 +103,7 @@ Just replace `https://open.spotify.com/` with `spotify:` and `/` with `:`.
|
||||
### Radio Stations
|
||||
```bash
|
||||
# TuneIn Radio
|
||||
--source TUNEIN --location "/v1/playbook/station/s33828"
|
||||
--source TUNEIN --location "/v1/playback/station/s33828"
|
||||
|
||||
# Internet Radio Stream
|
||||
--source LOCAL_INTERNET_RADIO --location "https://stream.example.com/jazz"
|
||||
@@ -249,7 +249,7 @@ soundtouch-cli --host 192.168.1.100 preset store \
|
||||
# Kids' bedtime stories
|
||||
soundtouch-cli --host 192.168.1.100 preset store \
|
||||
--slot 3 --source TUNEIN \
|
||||
--location "/v1/playbook/station/bedtime-stories" \
|
||||
--location "/v1/playback/station/bedtime-stories" \
|
||||
--name "Bedtime Stories"
|
||||
```
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ soundtouch-cli --host 192.168.1.10 preset store \
|
||||
soundtouch-cli --host 192.168.1.10 preset store \
|
||||
--slot 2 \
|
||||
--source TUNEIN \
|
||||
--location "/v1/playbook/station/s33828" \
|
||||
--location "/v1/playback/station/s33828" \
|
||||
--name "K-LOVE Radio"
|
||||
|
||||
# Store internet radio
|
||||
@@ -956,7 +956,7 @@ soundtouch-cli --host 192.168.1.10 station add \
|
||||
# Remove a station (use location from browse/search results)
|
||||
soundtouch-cli --host 192.168.1.10 station remove \
|
||||
--source TUNEIN \
|
||||
--location "/v1/playbook/station/s33828"
|
||||
--location "/v1/playback/station/s33828"
|
||||
```
|
||||
|
||||
**Workflow Example - Discover and Play New Content:**
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
SoundTouch speakers communicate with cloud services over HTTPS. For the local service to work over HTTPS, speakers must trust the AfterTouch Root CA. The service manages this automatically — it generates a CA on first start and the web UI guides you through installing it on each speaker as part of the migration flow.
|
||||
|
||||
> ### ⚠️ Speakers connect to `:443`, AfterTouch defaults to `:8443`
|
||||
>
|
||||
> Speakers build their target URLs from Bose hostnames *without* an explicit port, so they connect on the default HTTPS port **443**. AfterTouch's built-in HTTPS listener defaults to **8443** because port 443 is privileged on most Unix systems.
|
||||
>
|
||||
> **If you do nothing, speakers will fail with `Curl 7` / connection refused and nothing will appear in the AfterTouch HTTP log.**
|
||||
>
|
||||
> Pick one of the three options under [Binding to port 443](#binding-to-port-443) below. The settings page in the web UI shows a ✅ / ❌ indicator for `:443` reachability so you can confirm the routing is in place.
|
||||
|
||||
---
|
||||
|
||||
## How TLS works in AfterTouch
|
||||
@@ -41,9 +49,40 @@ http://<server>:8000/setup/ca.crt
|
||||
|
||||
Speakers expect HTTPS on the default port 443. Since binding to port 443 requires elevated privileges, you have three options:
|
||||
|
||||
1. **Port forwarding (recommended)**: Run the service on port 8443 and forward port 443 to it using `iptables` or your firewall/router.
|
||||
2. **Capabilities**: Grant the binary permission to bind low ports: `sudo setcap 'cap_net_bind_service=+ep' ./soundtouch-service`
|
||||
3. **Reverse proxy**: Use Nginx or Caddy in front of the service (see below).
|
||||
1. **Port forwarding (recommended)**: Run the service on port 8443 and forward port 443 to it using `iptables` or your firewall/router. Inside an LXC/Docker container or on the host:
|
||||
|
||||
```bash
|
||||
iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 8443
|
||||
iptables -t nat -A OUTPUT -p tcp --dport 443 -j REDIRECT --to-port 8443
|
||||
```
|
||||
|
||||
The first rule covers traffic arriving from speakers; the second covers loopback connections from the host itself (useful for the in-built pre-flight probe).
|
||||
|
||||
2. **Capabilities**: Grant the binary permission to bind low ports and start the listener directly on `:443`:
|
||||
|
||||
```bash
|
||||
sudo setcap 'cap_net_bind_service=+ep' ./soundtouch-service
|
||||
./soundtouch-service --https-port=443
|
||||
```
|
||||
|
||||
3. **Reverse proxy**: Use Nginx or Caddy on `:443` in front of the service (see below).
|
||||
|
||||
### Confirming `:443` is reachable
|
||||
|
||||
After applying any of the options above, open the AfterTouch web UI → **Settings**. The Target Domain row will show a second line:
|
||||
|
||||
* ✅ `:443 reachable on localhost and <IP> (forwarded to :8443)` — you're good.
|
||||
* ❌ `Speakers connect to :443 but AfterTouch listens on :8443.` — the routing is missing or not yet active.
|
||||
|
||||
A third line follows from the browser itself, which sits on the LAN exactly where the speakers do. The browser can't distinguish an untrusted-CA TLS error from a connection refusal, so it uses timing as a heuristic: a fast error means "no listener / firewall reset", a slower one means "something answered TCP". When the server-side and browser-side checks disagree, the UI flags it — that almost always means NAT, split-horizon DNS, or a host firewall sitting between AfterTouch and the LAN.
|
||||
|
||||
The same check runs once at service startup and prints a `[WARN]` log line if `:443` is unreachable, with the exact iptables/setcap commands for your current listener port.
|
||||
|
||||
#### When this check is shown
|
||||
|
||||
The `:443` indicator is only displayed when **AfterTouch's DNS interception is enabled** (Settings → "Enable DNS Discovery Server"). The check is only meaningful for the **DNS migration method**, where speakers reach AfterTouch via intercepted Bose hostnames and therefore on the implicit `:443`. The other migration method — writing direct `https://<host>:8443/...` URLs into the speaker's private config via SSH — uses the port that's literally in the URL, so `:443` is irrelevant and the check would only add noise.
|
||||
|
||||
If you intercept Bose hostnames **outside** AfterTouch (Pi-hole, router DNS rule, `/etc/hosts` on a gateway), the UI gate above will hide the indicator. The data is still in the `GET /setup/settings` JSON response (`https_443_localhost_reachable`, `https_443_lan_reachable`, `https_443_lan_host`) if you want to inspect it directly, or you can briefly enable AfterTouch's DNS server to see the indicator render.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -206,6 +206,82 @@ Each speaker is migrated independently. You can run multiple migrations in paral
|
||||
|
||||
---
|
||||
|
||||
## Alternative: CLI-driven factory-reset workflow
|
||||
|
||||
If you prefer scripting the migration, or the wizard isn't an option (headless server, automation, batch onboarding of many speakers), `soundtouch-cli` exposes the same building blocks. The flow below is **not** an in-place migration — it factory-resets the speaker and brings it up fresh against AfterTouch, so any data Bose preserved on the device is wiped. Use this when:
|
||||
|
||||
- You're starting from a factory-reset speaker anyway.
|
||||
- The wizard's in-place migration didn't take and you want a clean slate.
|
||||
- You're scripting setup for many speakers and want a reproducible recipe.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- AfterTouch service running and reachable at a stable URL (e.g., `https://soundtouch.local` from your `.env`).
|
||||
- The speaker reachable on its current IP (passed as `--host`).
|
||||
- For the AP-mode handover step, your laptop must be able to join the speaker's `Bose SoundTouch` Wi-Fi (you'll switch between home Wi-Fi and the speaker's AP).
|
||||
|
||||
### The full sequence
|
||||
|
||||
```bash
|
||||
# 1. Plan what the reset+pair pipeline will write (dry run, no changes yet).
|
||||
soundtouch-cli --host 192.168.1.50 setup plan \
|
||||
--reset=true --include-pair=false \
|
||||
--service-url='https://soundtouch.local'
|
||||
|
||||
# 2. Trigger the factory reset. The speaker reboots into AP mode.
|
||||
soundtouch-cli --host 192.168.1.50 setup factory-reset
|
||||
|
||||
# --- Manual step: join the speaker's Wi-Fi AP (SSID "Bose SoundTouch ...") ---
|
||||
|
||||
# 3. Wait for the AP-mode endpoint to answer.
|
||||
soundtouch-cli setup wait-ap
|
||||
|
||||
# 4. Push your home Wi-Fi credentials to the speaker.
|
||||
# Run twice if the first attempt's ACK races the AP teardown — the second
|
||||
# one is a no-op if the first succeeded.
|
||||
soundtouch-cli setup wifi-push --ssid="YourHomeSSID" --pass='your-wifi-password'
|
||||
|
||||
# --- Manual step: switch your laptop back to the home Wi-Fi network ---
|
||||
|
||||
# 5. Wait for the speaker to come back online on the home network.
|
||||
# --match takes the last 4-6 hex chars of the speaker's MAC (visible on
|
||||
# the bottom of the device).
|
||||
soundtouch-cli setup wait-online --match=42CAFE
|
||||
|
||||
# 6. Pair the speaker with an AfterTouch account.
|
||||
# --mode=full runs the canonical WebSocket SETUP sequence (matches the
|
||||
# Bose app's flow); --account is the 7-digit account ID AfterTouch
|
||||
# should attach the speaker to.
|
||||
soundtouch-cli --host 192.168.1.50 setup pair \
|
||||
--mode=full --account=1111111 \
|
||||
--service-url='https://soundtouch.local'
|
||||
```
|
||||
|
||||
### Verifying the result
|
||||
|
||||
After pairing completes:
|
||||
|
||||
- The speaker should appear on the **Devices** tab in the web UI.
|
||||
- AUX should switch and play audio when selected.
|
||||
- Pressing presets should fetch their content from AfterTouch (the `[LOG]` rows on the service confirm).
|
||||
- TuneIn search and playback should work end-to-end.
|
||||
|
||||
If any of these fail post-pair, see [Troubleshooting](TROUBLESHOOTING.md) — most commonly the speaker just needs a power cycle to pick up everything cleanly.
|
||||
|
||||
### Differences vs the wizard
|
||||
|
||||
| Aspect | Wizard (in-place migration) | CLI factory-reset workflow |
|
||||
|-------------------------------------|---------------------------------------------------------|---------------------------------------------------------|
|
||||
| Preserves speaker's existing state | yes (Presets, recents, attached account) | **no** — wipes everything |
|
||||
| Requires Wi-Fi-network switching | no | yes (laptop joins speaker AP, then home network) |
|
||||
| Scriptable / reproducible | clickable, not scriptable | full bash recipe |
|
||||
| Cloud-side data (Bose Marge backup) | preserved if Sync ran while cloud was alive | not relevant — fresh account on AfterTouch |
|
||||
| Best for | "I want this speaker to keep working with what's on it" | "I want a clean, reproducible setup against AfterTouch" |
|
||||
|
||||
The wizard is still the recommended path for a one-off migration of an existing setup. The CLI workflow is the right choice when you're scripting, batching, or already starting from a reset.
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
If you need to undo a migration:
|
||||
|
||||
@@ -474,7 +474,7 @@ Catch-all endpoint that signals the matching pre-flight probe channel. Used inte
|
||||
#### `GET /bmx/registry/v1/services`
|
||||
Returns available media services for device registration.
|
||||
|
||||
#### `GET /bmx/tunein/v1/playbook/station/{stationID}`
|
||||
#### `GET /bmx/tunein/v1/playback/station/{stationID}`
|
||||
Provides TuneIn station playback information.
|
||||
|
||||
#### `GET /bmx/tunein/v1/podcast/{podcastID}`
|
||||
|
||||
@@ -105,6 +105,62 @@ iperf3 -c 192.168.1.1 # If iperf server available
|
||||
|
||||
## 🌐 **Connection Issues**
|
||||
|
||||
### ❌ Speaker logs `Curl 7, http 0` and AfterTouch sees no HTTP requests
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
In the speaker's log (see [DEVICE-LOGGING.md](../DEVICE-LOGGING.md#1-accessing-system-logs-requires-root) for the SSH/`logread` setup — the filtered command `logread -f | grep -v '127.0.0.1'` is what you want here):
|
||||
|
||||
```
|
||||
SimpleURLFetcher: retry needed, Curl 7, http 0
|
||||
```
|
||||
|
||||
In the AfterTouch service log: plenty of `[DNS] Intercepted query …` lines but **zero** HTTP requests after each DNS lookup.
|
||||
|
||||
**Cause:** speakers connect to Bose hostnames over implicit HTTPS, i.e. port **443**. AfterTouch's built-in HTTPS listener defaults to **8443** because 443 is privileged. The speaker resolves the right IP, dials `:443`, and gets connection refused — which is what `Curl 7` reports.
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
curl -ksS -o /dev/null -w "443=%{http_code}\n" https://localhost:443/
|
||||
curl -ksS -o /dev/null -w "8443=%{http_code}\n" https://localhost:8443/
|
||||
```
|
||||
|
||||
Expected when the misconfiguration is present: `443=000` plus a `curl: (7) Failed to connect …` line, `8443=200` (or any 3-digit code).
|
||||
|
||||
**Fix:** route `:443` to AfterTouch's HTTPS listener — see [HTTPS-SETUP.md → Binding to port 443](HTTPS-SETUP.md#binding-to-port-443). The AfterTouch settings page shows a ✅ / ❌ indicator for `:443` reachability once the routing is in place.
|
||||
|
||||
### ❌ Presets flash then revert to "Select a preset" after a factory reset
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- You factory-reset a SoundTouch (Wave / 10 / 20 / 30 / …) that was previously migrated.
|
||||
- After reconnecting it to Wi-Fi, AfterTouch sees the speaker again, but pressing a preset on the device or in the app makes the display briefly show the preset name and then revert to *"Select a preset or explore music in the SoundTouch App"*.
|
||||
- Spotify presets show the same revert unless Spotify Connect is started from the mobile app first.
|
||||
- The speaker's `/sources` is missing TUNEIN / LOCAL_INTERNET_RADIO / DEEZER / your linked Spotify account — only AUX, BLUETOOTH, AIRPLAY, the SpotifyConnectUserName placeholder, NOTIFICATION, and QPLAY appear.
|
||||
|
||||
**Cause:**
|
||||
|
||||
A factory reset wipes `/mnt/nv/BoseApp-Persistence/1/Marge.xml` — the file that carries the speaker's auth token for the AfterTouch (or Bose) cloud service. The migrated URL configuration is preserved (it lives in `envswitch`), so the speaker keeps talking to AfterTouch, but with no token it can't authenticate for preset playback. Separately, the device's `/sources` cache is reduced until it receives a `<sourcesUpdated/>` notification.
|
||||
|
||||
**Fix:**
|
||||
|
||||
1. **Re-open the Migration tab** in the AfterTouch UI. The wizard reads `/info`, sees `margeAccountUUID` is empty, and renders:
|
||||
|
||||
> **Current: ❌ Not paired (factory-reset or never paired) — set an ID to pair as part of Apply**
|
||||
|
||||
The devices list now also shows a `⚠ Not paired — re-pair` badge next to such speakers, so you don't have to remember to open the Migration tab cold.
|
||||
|
||||
2. **Pick the previously-used account ID** from the "pick from datastore" dropdown (if AfterTouch remembers it), or click **Generate** for a fresh one.
|
||||
|
||||
3. **Click Apply.** The wizard runs `pair-account` along with the rest, recreating `Marge.xml` on the device with the chosen ID.
|
||||
|
||||
4. **Click Data Sync** (Tab 3). AfterTouch persists the speaker's presets/recents/sources and posts a `<sourcesUpdated/>` notification to the device — the missing TUNEIN / LOCAL_INTERNET_RADIO / DEEZER / linked Spotify entries reappear in `/sources` automatically.
|
||||
|
||||
5. Press a preset. It should play normally.
|
||||
|
||||
If presets still won't play after step 5, capture `logread -f | grep -v '127.0.0.1:'` on the speaker (see [DEVICE-LOGGING.md](../DEVICE-LOGGING.md#1-accessing-system-logs-requires-root)) while pressing the preset and file an issue with the snippet — the lines around the failed playback name the deeper cause.
|
||||
|
||||
### ❌ "Connection refused"
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 512 KiB After Width: | Height: | Size: 518 KiB |
|
Before Width: | Height: | Size: 463 KiB After Width: | Height: | Size: 482 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 97 KiB |
@@ -309,7 +309,7 @@ Now Playing:
|
||||
Track: K-LOVE Radio
|
||||
|
||||
Content Details:
|
||||
Location: /v1/playbook/station/s33828
|
||||
Location: /v1/playback/station/s33828
|
||||
```
|
||||
|
||||
**LOCAL_INTERNET_RADIO:**
|
||||
@@ -341,7 +341,7 @@ go run ./cmd/soundtouch-cli --host 192.168.1.100 play now --verbose
|
||||
Shows additional information:
|
||||
```
|
||||
Content Details:
|
||||
Location: /v1/playbook/station/s33828
|
||||
Location: /v1/playback/station/s33828
|
||||
Content Type: stationurl
|
||||
Item Name: K-LOVE Radio
|
||||
Presetable: true
|
||||
@@ -367,7 +367,7 @@ Content Details:
|
||||
| **Spotify Album** | `spotify:album:ID` | `spotify:album:4aawyAB9vmqN3uQ7FjRGTy` |
|
||||
| **Spotify Artist** | `spotify:artist:ID` | `spotify:artist:6APm8EjxOHSYM5B4i3vT3q` |
|
||||
| **Spotify Track** | `spotify:track:ID` | `spotify:track:17GmwQ9Q3MTAz05OokmNNB` |
|
||||
| **TUNEIN Radio** | `/v1/playbook/station/ID` | `/v1/playbook/station/s33828` |
|
||||
| **TUNEIN Radio** | `/v1/playback/station/ID` | `/v1/playback/station/s33828` |
|
||||
| **Internet Radio** | `URL or encoded URL` | `https://stream.example.com/radio` |
|
||||
| **STORED_MUSIC** | `Container ID` | `6_a2874b5d_4f83d999` |
|
||||
| **LOCAL_MUSIC** | `album:ID` or `track:ID` | `album:983`, `track:2579` |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# soundtouch-web: remaining features
|
||||
|
||||
Three features complete the parity gap between soundtouch-web and the Stockholm
|
||||
Four features complete the parity gap between soundtouch-web and the Stockholm
|
||||
app's local-control functionality. Everything else in Stockholm (OAuth flows,
|
||||
setup wizard, service account linking, onboarding, analytics) is cloud
|
||||
infrastructure that is either shut down or already handled by soundtouch-service.
|
||||
@@ -86,6 +86,48 @@ rename and network/firmware info.
|
||||
|
||||
---
|
||||
|
||||
## 4. Render stereo pairs as a single device
|
||||
|
||||
Today soundtouch-web shows the two halves of a stereo pair (formed via
|
||||
`/addGroup` — see issue #252) as independent entries in the device list. The
|
||||
Bose app collapsed a paired ST10 set into one "L+R" entry; restoring that
|
||||
presentation closes the perception gap BirdyBA flagged at
|
||||
<https://github.com/gesellix/Bose-SoundTouch/issues/252#issuecomment-4458140305>.
|
||||
|
||||
**Device API:**
|
||||
- `GET /getGroup` on each speaker — returns the current `<group>` with
|
||||
`<masterDeviceId>` + `<roles>` (each `<groupRole>` carries the speaker's
|
||||
deviceId, role `LEFT|RIGHT`, and ipAddress)
|
||||
- Empty `<group/>` means the speaker is standalone
|
||||
- Querying the master and slave returns the same `<group>` payload, so either
|
||||
side is sufficient to detect the pair
|
||||
|
||||
**Backend:**
|
||||
- During device-list assembly, call `GET /getGroup` for each discovered device
|
||||
in parallel (matches the propagation pattern already used by
|
||||
`soundtouch-cli group create` in `cmd/soundtouch-cli/cmd_group.go`)
|
||||
- Bucket devices by `<masterDeviceId>` — each bucket emits one entry in the
|
||||
list response. Standalone devices stay as their own bucket-of-one
|
||||
- Expose pair metadata on the list entry so the UI can render role chips
|
||||
(`L`/`R`) and resolve role → physical device for actions
|
||||
|
||||
**Frontend:**
|
||||
- Device list collapses paired devices into one card titled with both names
|
||||
(e.g. `"Wohnzimmer L+R"`) and role chips
|
||||
- Clicking the card opens a device-detail page that exposes both per-role
|
||||
status and a "Dissolve pair" action (DELETE flow, already wired in
|
||||
`soundtouch-cli group remove` and in fakespeaker's `/removeGroup` GET)
|
||||
- Standalone speakers continue to render as today
|
||||
|
||||
**Note:** Pair lifecycle (create / rename / remove) already works
|
||||
end-to-end — `pkg/client` group endpoints + `cmd/soundtouch-cli/cmd_group.go`,
|
||||
covered by tests in `cmd/soundtouch-cli/cmd_group_test.go` and exercisable
|
||||
against the fake speaker's group routes
|
||||
(`pkg/service/testing/fakespeaker/fakespeaker.go`). This task is purely about
|
||||
presentation in soundtouch-web's device list — no protocol work required.
|
||||
|
||||
---
|
||||
|
||||
## Decide later
|
||||
|
||||
| Feature | Reason |
|
||||
|
||||
@@ -83,7 +83,7 @@ go run main.go 192.168.1.100
|
||||
🎯 Using generic ContentItem selection...
|
||||
Content: K-LOVE Radio
|
||||
Source: TUNEIN
|
||||
Location: /v1/playbook/station/s33828
|
||||
Location: /v1/playback/station/s33828
|
||||
✅ Successfully selected content using ContentItem
|
||||
|
||||
✅ Content selection demo completed!
|
||||
|
||||
@@ -209,7 +209,7 @@ func demoGenericContentItem(c *client.Client) error {
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: "stationurl",
|
||||
Location: "/v1/playbook/station/s33828", // K-LOVE Radio
|
||||
Location: "/v1/playback/station/s33828", // K-LOVE Radio
|
||||
SourceAccount: "",
|
||||
IsPresetable: true,
|
||||
ItemName: "K-LOVE Radio",
|
||||
|
||||
@@ -140,7 +140,7 @@ err := client.AddStation("TUNEIN", "", "c121508", "Jazz FM")
|
||||
// Remove station from collection
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Location: "/v1/playbook/station/s33828",
|
||||
Location: "/v1/playback/station/s33828",
|
||||
}
|
||||
err := client.RemoveStation(contentItem)
|
||||
```
|
||||
|
||||
@@ -71,7 +71,7 @@ go run . 192.168.1.100
|
||||
|
||||
2. K-LOVE Radio
|
||||
Source: TUNEIN
|
||||
Location: /v1/playbook/station/s33828
|
||||
Location: /v1/playback/station/s33828
|
||||
Created: 2024-01-15 09:15:00
|
||||
|
||||
🆓 Available slots: [3 4 5 6]
|
||||
@@ -179,7 +179,7 @@ Location: "spotify:track:17GmwQ9Q3MTAz05OokmNNB"
|
||||
|
||||
```go
|
||||
// TuneIn
|
||||
Location: "/v1/playbook/station/s33828"
|
||||
Location: "/v1/playback/station/s33828"
|
||||
|
||||
// Internet Radio
|
||||
Location: "https://stream.example.com/radio"
|
||||
|
||||
@@ -226,7 +226,7 @@ func storeRadioStation(c *client.Client) error {
|
||||
radioContent := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: "stationurl",
|
||||
Location: "/v1/playbook/station/s33828", // K-LOVE
|
||||
Location: "/v1/playback/station/s33828", // K-LOVE
|
||||
SourceAccount: "",
|
||||
IsPresetable: true,
|
||||
ItemName: "K-LOVE Radio",
|
||||
@@ -329,7 +329,7 @@ func demonstrateWebSocketEvents(c *client.Client) error {
|
||||
testContent := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: "stationurl",
|
||||
Location: "/v1/playbook/station/s25111", // BBC Radio 1
|
||||
Location: "/v1/playback/station/s25111", // BBC Radio 1
|
||||
SourceAccount: "",
|
||||
IsPresetable: true,
|
||||
ItemName: "BBC Radio 1",
|
||||
|
||||
@@ -597,6 +597,16 @@ type ServiceDeviceInfo struct {
|
||||
DiscoveryMethod string `json:"discovery_method,omitempty"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
Components []ServiceComponent `json:"components,omitempty" xml:"-"`
|
||||
// CreatedOn is the ISO8601 timestamp the device was first
|
||||
// registered against the account. Preserved across renames so
|
||||
// AfterTouch's PUT response matches real Bose's "first paired
|
||||
// in 2017" semantics rather than rewriting `now()` on every
|
||||
// update. Empty for never-persisted records.
|
||||
CreatedOn string `json:"created_on,omitempty" xml:"-"`
|
||||
// UpdatedOn is the ISO8601 timestamp of the most recent change
|
||||
// to the device record (rename, IP refresh, …). Refreshed by
|
||||
// every SaveDeviceInfo write that mutates a known device.
|
||||
UpdatedOn string `json:"updated_on,omitempty" xml:"-"`
|
||||
}
|
||||
|
||||
// ServiceComponent represents a hardware or software component of a device.
|
||||
|
||||
@@ -20,11 +20,35 @@ import (
|
||||
// TuneIn endpoint templates used to resolve station and stream URLs.
|
||||
const (
|
||||
TuneInDescribe = "https://opml.radiotime.com/describe.ashx?id=%s"
|
||||
TuneInStream = "http://opml.radiotime.com/Tune.ashx?id=%s&formats=mp3,aac,ogg"
|
||||
TuneInNavigateAshx = "http://opml.radiotime.com/?render=json"
|
||||
TuneInSearchAPI = "https://api.radiotime.com/profiles?fulltextsearch=true&version=1.3&query="
|
||||
|
||||
// DefaultTuneInStreamFormats is the comma-separated format list
|
||||
// AfterTouch sends to TuneIn's Tune.ashx by default. Matches the
|
||||
// pre-2026-05-10 behaviour from before PR #249 added "hls"
|
||||
// unconditionally — HLS playback is broken on SoundTouch 10/
|
||||
// firmware 27 (and probably the rest of the line; see #292).
|
||||
// Speakers receive an .m3u8 playlist URL they can't parse, blink
|
||||
// amber, fall silent. Operators with HLS-compatible speakers can
|
||||
// override via Settings.TuneInStreamFormats.
|
||||
DefaultTuneInStreamFormats = "mp3,aac,ogg"
|
||||
)
|
||||
|
||||
// TuneInStream returns the formatted Tune.ashx URL for a station or
|
||||
// podcast. The formats argument controls the formats= query parameter;
|
||||
// empty falls back to DefaultTuneInStreamFormats. Operators can set
|
||||
// arbitrary lists (e.g. "mp3,aac,ogg,hls" to re-enable HLS, or
|
||||
// "aac" to force a single format) via Settings.TuneInStreamFormats.
|
||||
// The value is passed through verbatim — no token-level validation.
|
||||
func TuneInStream(stationID, formats string) string {
|
||||
formats = strings.TrimSpace(formats)
|
||||
if formats == "" {
|
||||
formats = DefaultTuneInStreamFormats
|
||||
}
|
||||
|
||||
return fmt.Sprintf("http://opml.radiotime.com/Tune.ashx?id=%s&formats=%s", stationID, formats)
|
||||
}
|
||||
|
||||
var tuneInClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// allowedTuneInHosts restricts outbound fetches to known TuneIn domains.
|
||||
@@ -555,8 +579,10 @@ func TuneInNavigateProfile(encodedURI string) (*models.BmxNavResponse, error) {
|
||||
}
|
||||
|
||||
// TuneInPlayback resolves a live radio station and returns a Bose-compatible
|
||||
// playback response with primary stream and variants.
|
||||
func TuneInPlayback(stationID string) (*models.BmxPlaybackResponse, error) {
|
||||
// playback response with primary stream and variants. formats is the
|
||||
// comma-separated list passed to Tune.ashx?formats=… ; empty falls back to
|
||||
// DefaultTuneInStreamFormats (the SoundTouch-line-compatible shape).
|
||||
func TuneInPlayback(stationID, formats string) (*models.BmxPlaybackResponse, error) {
|
||||
describeURL := fmt.Sprintf(TuneInDescribe, stationID)
|
||||
|
||||
resp, err := http.Get(describeURL)
|
||||
@@ -588,7 +614,7 @@ func TuneInPlayback(stationID string) (*models.BmxPlaybackResponse, error) {
|
||||
|
||||
station := opml.Body.Outline.Station
|
||||
|
||||
streamReq := fmt.Sprintf(TuneInStream, stationID)
|
||||
streamReq := TuneInStream(stationID, formats)
|
||||
|
||||
streamResp, err := http.Get(streamReq)
|
||||
if err != nil {
|
||||
@@ -697,8 +723,9 @@ func TuneInPodcastInfo(podcastID, encodedName string) (*models.BmxPodcastInfoRes
|
||||
}
|
||||
|
||||
// TuneInPlaybackPodcast resolves an on-demand podcast episode and returns
|
||||
// a playback response suitable for SoundTouch devices.
|
||||
func TuneInPlaybackPodcast(podcastID string) (*models.BmxPlaybackResponse, error) {
|
||||
// a playback response suitable for SoundTouch devices. formats has the
|
||||
// same semantics as in TuneInPlayback.
|
||||
func TuneInPlaybackPodcast(podcastID, formats string) (*models.BmxPlaybackResponse, error) {
|
||||
describeURL := fmt.Sprintf(TuneInDescribe, podcastID)
|
||||
|
||||
resp, err := http.Get(describeURL)
|
||||
@@ -733,7 +760,7 @@ func TuneInPlaybackPodcast(podcastID string) (*models.BmxPlaybackResponse, error
|
||||
|
||||
topic := opml.Body.Outline.Topic
|
||||
|
||||
streamReq := fmt.Sprintf(TuneInStream, podcastID)
|
||||
streamReq := TuneInStream(podcastID, formats)
|
||||
|
||||
streamResp, err := http.Get(streamReq)
|
||||
if err != nil {
|
||||
|
||||
@@ -221,3 +221,52 @@ func TestTuneInPodcastInfo_Base64(t *testing.T) {
|
||||
t.Errorf("Expected name %s, got %s", name, resp.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTuneInStream_EmptyFormatsUsesDefault pins the post-#292 contract:
|
||||
// AfterTouch must NOT request HLS streams from TuneIn unless the
|
||||
// operator has explicitly opted in. The default request shape is
|
||||
// "mp3,aac,ogg" — matches pre-2026-05-10 behaviour and works on
|
||||
// every SoundTouch model verified. PR #249 had added "hls"
|
||||
// unconditionally; that regressed playback on ST10/firmware 27 (the
|
||||
// speaker can't parse the .m3u8 playlist TuneIn returns when HLS is
|
||||
// in the format list).
|
||||
func TestTuneInStream_EmptyFormatsUsesDefault(t *testing.T) {
|
||||
got := TuneInStream("s33828", "")
|
||||
|
||||
if strings.Contains(got, "hls") {
|
||||
t.Errorf("default TuneInStream URL must NOT request HLS; got %s", got)
|
||||
}
|
||||
|
||||
want := "formats=" + DefaultTuneInStreamFormats
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("default TuneInStream URL must request %q; got %s", want, got)
|
||||
}
|
||||
|
||||
if !strings.Contains(got, "id=s33828") {
|
||||
t.Errorf("TuneInStream URL must carry the station ID; got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTuneInStream_OverrideHonoured verifies the opt-in path: when an
|
||||
// operator sets Settings.TuneInStreamFormats to a custom list,
|
||||
// TuneInStream passes it through verbatim. Two sub-cases catch the
|
||||
// common opt-in (re-add hls) and a more drastic override (single
|
||||
// format) so a future regression in the trim/fallback logic surfaces
|
||||
// at compile/test time.
|
||||
func TestTuneInStream_OverrideHonoured(t *testing.T) {
|
||||
cases := []struct {
|
||||
formats string
|
||||
want string
|
||||
}{
|
||||
{"mp3,aac,ogg,hls", "formats=mp3,aac,ogg,hls"}, // opt-in: re-add HLS
|
||||
{"aac", "formats=aac"}, // single format
|
||||
{" mp3 ", "formats=mp3"}, // whitespace stripped
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := TuneInStream("s33828", tc.formats)
|
||||
if !strings.Contains(got, tc.want) {
|
||||
t.Errorf("TuneInStream(%q) URL must contain %q; got %s", tc.formats, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,6 +565,8 @@ func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.Servic
|
||||
MacAddress string `xml:"macAddress"`
|
||||
} `xml:"networkInfo"`
|
||||
DiscoveryMethod string `xml:"discoveryMethod"`
|
||||
CreatedOn string `xml:"createdOn,omitempty"`
|
||||
UpdatedOn string `xml:"updatedOn,omitempty"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(data, &info); err != nil {
|
||||
@@ -577,6 +579,8 @@ func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.Servic
|
||||
ProductCode: fmt.Sprintf("%s %s", info.Type, info.ModuleType),
|
||||
Name: info.Name,
|
||||
DiscoveryMethod: info.DiscoveryMethod,
|
||||
CreatedOn: info.CreatedOn,
|
||||
UpdatedOn: info.UpdatedOn,
|
||||
}
|
||||
|
||||
for _, comp := range info.Components {
|
||||
@@ -789,6 +793,8 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
|
||||
MacAddress string `xml:"macAddress"`
|
||||
} `xml:"networkInfo"`
|
||||
DiscoveryMethod string `xml:"discoveryMethod"`
|
||||
CreatedOn string `xml:"createdOn,omitempty"`
|
||||
UpdatedOn string `xml:"updatedOn,omitempty"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(data, &info); err != nil {
|
||||
@@ -1192,6 +1198,8 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
|
||||
Components []componentXML `xml:"components>component"`
|
||||
NetworkInfo []NetworkInfoXML `xml:"networkInfo"`
|
||||
DiscoveryMethod string `xml:"discoveryMethod,omitempty"`
|
||||
CreatedOn string `xml:"createdOn,omitempty"`
|
||||
UpdatedOn string `xml:"updatedOn,omitempty"`
|
||||
}
|
||||
|
||||
// Parsing product code back to type and moduleType (best effort)
|
||||
@@ -1203,6 +1211,8 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
|
||||
Type: devType,
|
||||
ModuleType: moduleType,
|
||||
DiscoveryMethod: info.DiscoveryMethod,
|
||||
CreatedOn: info.CreatedOn,
|
||||
UpdatedOn: info.UpdatedOn,
|
||||
}
|
||||
|
||||
if ix.DiscoveryMethod == "" {
|
||||
@@ -1266,6 +1276,21 @@ func (ds *DataStore) mergeWithExistingDeviceInfo(account, device string, info *m
|
||||
if info.DiscoveryMethod == "" {
|
||||
info.DiscoveryMethod = existing.DiscoveryMethod
|
||||
}
|
||||
|
||||
// CreatedOn is set once at first persistence and never re-derived
|
||||
// from inbound data — preserve unconditionally so the
|
||||
// "first-paired" timestamp survives renames, IP refreshes, etc.
|
||||
// UpdatedOn is the opposite: every write that reaches here is by
|
||||
// definition an update, so callers that want it refreshed must
|
||||
// set it explicitly. If they didn't, fall back to the existing
|
||||
// value (better than a regression to empty).
|
||||
if existing.CreatedOn != "" {
|
||||
info.CreatedOn = existing.CreatedOn
|
||||
}
|
||||
|
||||
if info.UpdatedOn == "" {
|
||||
info.UpdatedOn = existing.UpdatedOn
|
||||
}
|
||||
}
|
||||
|
||||
func (ds *DataStore) parseProductCode(productCode string) (string, string) {
|
||||
@@ -2102,6 +2127,19 @@ type Settings struct {
|
||||
// reverse proxy on the same host. Override only if the proxy lives on a
|
||||
// different host within a known-good private subnet.
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
|
||||
|
||||
// TuneInStreamFormats overrides the comma-separated format list
|
||||
// AfterTouch sends to TuneIn's Tune.ashx (formats=…). Empty value
|
||||
// uses bmx.DefaultTuneInStreamFormats ("mp3,aac,ogg"), which
|
||||
// matches AfterTouch's pre-2026-05-10 behaviour and plays on
|
||||
// every SoundTouch model verified so far. PR #249 had added
|
||||
// "hls" unconditionally; that regressed playback on the
|
||||
// SoundTouch line (#292 — speaker can't parse the .m3u8 playlist
|
||||
// and blinks amber). Operators with HLS-compatible speakers can
|
||||
// set this to e.g. "mp3,aac,ogg,hls" via settings.json. The value
|
||||
// is passed through verbatim; AfterTouch does not validate the
|
||||
// individual format tokens.
|
||||
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
|
||||
}
|
||||
|
||||
// GetSettings retrieves the global service settings.
|
||||
|
||||
@@ -15,6 +15,26 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// tuneInStreamFormats returns the formats= list AfterTouch should send
|
||||
// to TuneIn's Tune.ashx, honouring Settings.TuneInStreamFormats when
|
||||
// set. Empty (the default) lets bmx.TuneInStream fall back to
|
||||
// bmx.DefaultTuneInStreamFormats — the SoundTouch-line-compatible
|
||||
// "mp3,aac,ogg" shape. Operators with HLS-capable speakers can set
|
||||
// the field to "mp3,aac,ogg,hls" (or any other comma-separated list)
|
||||
// in settings.json.
|
||||
func (s *Server) tuneInStreamFormats() string {
|
||||
if s == nil || s.ds == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
settings, err := s.ds.GetSettings()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return settings.TuneInStreamFormats
|
||||
}
|
||||
|
||||
// HandleBMXRegistry returns the BMX service registry.
|
||||
func (s *Server) HandleBMXRegistry(w http.ResponseWriter, _ *http.Request) {
|
||||
baseURL := s.serverURL
|
||||
@@ -62,7 +82,7 @@ func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
|
||||
resp, err := bmx.TuneInPlayback(stationID)
|
||||
resp, err := bmx.TuneInPlayback(stationID, s.tuneInStreamFormats())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -109,7 +129,7 @@ func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
podcastID := chi.URLParam(r, "podcastID")
|
||||
|
||||
resp, err := bmx.TuneInPlaybackPodcast(podcastID)
|
||||
resp, err := bmx.TuneInPlaybackPodcast(podcastID, s.tuneInStreamFormats())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -173,14 +193,28 @@ func (s *Server) HandleOrionToken(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleOrionPlayback returns Orion playback information.
|
||||
// HandleOrionPlayback returns Orion playback information for the
|
||||
// /core02/svc-bmx-adapter-orion/prod/orion/station?data=... endpoint
|
||||
// the speaker reaches by following its stored LOCAL_INTERNET_RADIO
|
||||
// preset's `location` attribute. The `data` query string is the
|
||||
// base64-encoded JSON blob (streamUrl/imageUrl/name) that the speaker
|
||||
// constructed when the preset was first saved; we just decode and
|
||||
// rewrap it into the Bose BmxPlaybackResponse shape via
|
||||
// bmx.PlayCustomStream.
|
||||
//
|
||||
// Requires a Bearer token in the `Authorization` header — same as
|
||||
// the rest of the BMX playback surface (TuneIn variants and the
|
||||
// orion token endpoint). Real speakers obtain the token via
|
||||
// POST /core02/svc-bmx-adapter-orion/prod/orion/token (HandleOrionToken)
|
||||
// before they ever follow a LOCAL_INTERNET_RADIO preset, so this
|
||||
// check shouldn't cost any legitimate caller.
|
||||
func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
data := chi.URLParam(r, "data")
|
||||
data := r.URL.Query().Get("data")
|
||||
|
||||
resp, err := bmx.PlayCustomStream(data)
|
||||
if err != nil {
|
||||
|
||||
@@ -87,7 +87,13 @@ func TestOrionPlayback(t *testing.T) {
|
||||
// Base64 encoded: {"streamUrl": "http://example.com/stream", "imageUrl": "http://example.com/img.jpg", "name": "Test Orion"}
|
||||
data := "eyJzdHJlYW1VcmwiOiAiaHR0cDovL2V4YW1wbGUuY29tL3N0cmVhbSIsICJpbWFnZVVybCI6ICJodHRwOi8vZXhhbXBsZS5jb20vaW1nLmpwZyIsICJuYW1lIjogIlRlc3QgT3Jpb24ifQ=="
|
||||
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/bmx/orion/v1/playback/station/"+data, nil)
|
||||
// Speakers reach this endpoint by following the `location` attribute
|
||||
// stored in a LOCAL_INTERNET_RADIO preset's contentItem — a GET to
|
||||
// the upstream path with `data` as a query string. The data is
|
||||
// already base64-URL-safe; passing it raw mirrors what the speaker
|
||||
// emits (Go's url package re-encodes any `=` padding for transport).
|
||||
req, _ := http.NewRequest("GET",
|
||||
ts.URL+"/core02/svc-bmx-adapter-orion/prod/orion/station?data="+url.QueryEscape(data), nil)
|
||||
req.Header.Set("Authorization", "Bearer mock-token")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -165,7 +171,7 @@ func TestBMXUnauthorized(t *testing.T) {
|
||||
{"GET", "/bmx/tunein/v1/playback/station/s123"},
|
||||
{"GET", "/bmx/tunein/v1/playback/episodes/p123"},
|
||||
{"GET", "/bmx/tunein/v1/playback/episode/p123"},
|
||||
{"POST", "/bmx/orion/v1/playback/station/data"},
|
||||
{"GET", "/core02/svc-bmx-adapter-orion/prod/orion/station?data=AAAA"},
|
||||
}
|
||||
|
||||
for _, tc := range paths {
|
||||
|
||||
@@ -588,7 +588,7 @@ func (s *Server) HandleMargeAddDevice(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceID, data, err := marge.AddDeviceToAccount(s.ds, account, body)
|
||||
deviceID, data, err := marge.AddDeviceToAccount(s.ds, account, body, r.RemoteAddr)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -600,6 +600,74 @@ func (s *Server) HandleMargeAddDevice(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeUpdateDevice handles the speaker's rename PUT against
|
||||
// /streaming/account/{account}/device/{device}. The speaker fires
|
||||
// this whenever the user renames it via the Bose App or via
|
||||
// `soundtouch-cli name set`; before this handler existed AfterTouch
|
||||
// returned 502, the speaker retried in a loop, and the App showed
|
||||
// the rename hanging indefinitely (issue #285).
|
||||
//
|
||||
// The expected payload mirrors the POST shape:
|
||||
//
|
||||
// <device deviceid="DEVID"><name>NEW</name><macaddress>DEVID</macaddress></device>
|
||||
//
|
||||
// AddDeviceToAccount is already an upsert via ds.SaveDeviceInfo, so
|
||||
// rather than introduce a parallel UpdateDevice function we route
|
||||
// the PUT through the same persistence path. The semantic delta is
|
||||
// purely in the HTTP envelope: 200 (not 201), no Location header,
|
||||
// and the deviceID in the body has to match the URL — a mismatch
|
||||
// means the speaker is targeting the wrong record and we refuse
|
||||
// rather than silently re-key.
|
||||
func (s *Server) HandleMargeUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
account := chi.URLParam(r, "account")
|
||||
if !validatePathID(account) {
|
||||
http.Error(w, "Invalid account ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device := chi.URLParam(r, "device")
|
||||
if !validatePathID(device) {
|
||||
http.Error(w, "Invalid device ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read body", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate body deviceID against the URL segment *before* the
|
||||
// upsert in AddDeviceToAccount runs — otherwise a mismatched PUT
|
||||
// would still persist a row for the body's deviceID before the
|
||||
// 400 response, leaving spurious state in the datastore.
|
||||
var probe struct {
|
||||
DeviceID string `xml:"deviceid,attr"`
|
||||
}
|
||||
if xmlErr := xml.Unmarshal(body, &probe); xmlErr != nil {
|
||||
http.Error(w, xmlErr.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if probe.DeviceID != device {
|
||||
http.Error(w,
|
||||
fmt.Sprintf("device ID in body (%q) does not match URL (%q)", probe.DeviceID, device),
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, data, err := marge.AddDeviceToAccount(s.ds, account, body, r.RemoteAddr)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeRemovePreset removes a preset for the specified account and device.
|
||||
func (s *Server) HandleMargeRemovePreset(w http.ResponseWriter, r *http.Request) {
|
||||
account := chi.URLParam(r, "account")
|
||||
|
||||
@@ -64,12 +64,16 @@ func TestMargeCreateAccount(t *testing.T) {
|
||||
t.Errorf("Expected 7-digit ID, got %v", resp.ID)
|
||||
}
|
||||
|
||||
// Verify it has default sources
|
||||
if len(resp.Sources) != 5 {
|
||||
t.Errorf("Expected 5 default sources, got %d", len(resp.Sources))
|
||||
// Verify default sources. AUX (id=10001, sourceproviderid=9) is
|
||||
// intentionally excluded from cloud responses — real Bose never
|
||||
// emitted AUX in /full; the speaker enumerates AUX from its own
|
||||
// hardware via isLocal=true in :8090/sources. See
|
||||
// pkg/service/marge/marge.go getAccountSources.
|
||||
if len(resp.Sources) != 4 {
|
||||
t.Errorf("Expected 4 cloud default sources (AUX excluded), got %d", len(resp.Sources))
|
||||
} else {
|
||||
if resp.Sources[0].ID != "10001" {
|
||||
t.Errorf("Expected first source ID 10001, got %s", resp.Sources[0].ID)
|
||||
if resp.Sources[0].ID != "10002" {
|
||||
t.Errorf("Expected first cloud source ID 10002 (INTERNET_RADIO), got %s", resp.Sources[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,10 +384,12 @@ func TestMargeAccountFullExcludesEmptyAmazonSource(t *testing.T) {
|
||||
t.Errorf("/full response must not include an empty-credential Amazon source; body:\n%s", bodyStr)
|
||||
}
|
||||
|
||||
// The 6 sources from lastDeviceID's stored Sources.xml must all be present.
|
||||
// Checked by sourceproviderid since <name> may hold a display name rather than the type string.
|
||||
// The cloud-visible sources from lastDeviceID's stored Sources.xml
|
||||
// must all be present. AUX (sourceproviderid=9) is intentionally
|
||||
// excluded — real Bose never emitted AUX in /full; the speaker
|
||||
// enumerates AUX from its own hardware via isLocal=true. See
|
||||
// pkg/service/marge/marge.go getAccountSources.
|
||||
for _, wantProviderID := range []string{
|
||||
"<sourceproviderid>9</sourceproviderid>", // AUX
|
||||
"<sourceproviderid>2</sourceproviderid>", // INTERNET_RADIO
|
||||
"<sourceproviderid>11</sourceproviderid>", // LOCAL_INTERNET_RADIO
|
||||
"<sourceproviderid>25</sourceproviderid>", // TUNEIN
|
||||
@@ -394,6 +400,11 @@ func TestMargeAccountFullExcludesEmptyAmazonSource(t *testing.T) {
|
||||
t.Errorf("/full response is missing source with %s; body:\n%s", wantProviderID, bodyStr)
|
||||
}
|
||||
}
|
||||
|
||||
// And explicitly assert AUX is NOT present.
|
||||
if strings.Contains(bodyStr, "<sourceproviderid>9</sourceproviderid>") {
|
||||
t.Errorf("/full response must not include AUX (sourceproviderid=9); body:\n%s", bodyStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeAccountSources(t *testing.T) {
|
||||
@@ -627,13 +638,16 @@ func TestMargeAccountSourcesNoDevices(t *testing.T) {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
// Verify that we get the default sources with correct IDs and empty display names
|
||||
// Verify that we get the default cloud sources with correct IDs. AUX
|
||||
// (id=10001) is intentionally excluded — real Bose never emitted AUX
|
||||
// in cloud responses; the speaker enumerates AUX from its own
|
||||
// hardware (isLocal=true on :8090/sources). See
|
||||
// pkg/service/marge/marge.go getAccountSources.
|
||||
expectedSnippets := []string{
|
||||
"<sources>",
|
||||
"<source id=\"10004\" type=\"Audio\"",
|
||||
"<source id=\"10003\" type=\"Audio\"",
|
||||
"<source id=\"10002\" type=\"Audio\"",
|
||||
"<source id=\"10001\" type=\"Audio\"",
|
||||
}
|
||||
|
||||
for _, snippet := range expectedSnippets {
|
||||
@@ -642,6 +656,10 @@ func TestMargeAccountSourcesNoDevices(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(bodyStr, "<source id=\"10001\"") {
|
||||
t.Errorf("Response must not include AUX (id=10001); body:\n%s", bodyStr)
|
||||
}
|
||||
|
||||
// Verify that no sources have empty display names
|
||||
if strings.Count(bodyStr, "displayName=\"\"") != 0 {
|
||||
t.Errorf("Expected no sources with empty displayName, got %d: %s", strings.Count(bodyStr, "displayName=\"\""), bodyStr)
|
||||
|
||||
@@ -180,6 +180,9 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
serverURLResolveError = err.Error()
|
||||
}
|
||||
|
||||
httpsListenerPort := PortFromHTTPSServerURL(httpsServerURL)
|
||||
probe443 := Check443Reachability(httpsListenerPort, serverURL, s.resolveServerURLIP, ProbeDialTimeoutInline)
|
||||
|
||||
// Mask secrets: return "***" if set so the UI can show "configured" without exposing the value.
|
||||
if spotifyClientSecret != "" {
|
||||
spotifyClientSecret = "***"
|
||||
@@ -190,34 +193,41 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"server_url": serverURL,
|
||||
"server_url_resolved_ip": serverURLResolvedIP,
|
||||
"server_url_resolve_error": serverURLResolveError,
|
||||
"https_server_url": httpsServerURL,
|
||||
"discovery_interval": discoveryInterval,
|
||||
"discovery_enabled": discoveryEnabled,
|
||||
"dns_enabled": dnsEnabled,
|
||||
"dns_running": dnsRunning,
|
||||
"dns_actual_bind": actualBind,
|
||||
"dns_upstream": strings.Join(dnsUpstream, ","),
|
||||
"dns_bind_addr": dnsBindAddr,
|
||||
"mirror_enabled": mirrorEnabled,
|
||||
"mirror_endpoints": mirrorEndpoints,
|
||||
"skip_mirror_endpoints": skipMirrorEndpoints,
|
||||
"preferred_source": preferredSource,
|
||||
"internal_paths": internalPaths,
|
||||
"redact_logs": redact,
|
||||
"log_bodies": logBody,
|
||||
"record_interactions": record,
|
||||
"shortcuts": shortcuts,
|
||||
"spotify_configured": spotifyConfigured,
|
||||
"spotify_client_id": spotifyClientID,
|
||||
"spotify_client_secret": spotifyClientSecret,
|
||||
"spotify_redirect_uri": spotifyRedirectURI,
|
||||
"amazon_configured": amazonConfigured,
|
||||
"amazon_client_id": amazonClientID,
|
||||
"amazon_client_secret": amazonClientSecret,
|
||||
"amazon_redirect_uri": amazonRedirectURI,
|
||||
"server_url": serverURL,
|
||||
"server_url_resolved_ip": serverURLResolvedIP,
|
||||
"server_url_resolve_error": serverURLResolveError,
|
||||
"https_server_url": httpsServerURL,
|
||||
"https_listener_port": httpsListenerPort,
|
||||
"https_443_check_skipped": probe443.Skipped,
|
||||
"https_443_localhost_reachable": probe443.Localhost.Reachable,
|
||||
"https_443_localhost_error": probe443.Localhost.Error,
|
||||
"https_443_lan_reachable": probe443.LAN.Reachable,
|
||||
"https_443_lan_error": probe443.LAN.Error,
|
||||
"https_443_lan_host": probe443.LANHost,
|
||||
"discovery_interval": discoveryInterval,
|
||||
"discovery_enabled": discoveryEnabled,
|
||||
"dns_enabled": dnsEnabled,
|
||||
"dns_running": dnsRunning,
|
||||
"dns_actual_bind": actualBind,
|
||||
"dns_upstream": strings.Join(dnsUpstream, ","),
|
||||
"dns_bind_addr": dnsBindAddr,
|
||||
"mirror_enabled": mirrorEnabled,
|
||||
"mirror_endpoints": mirrorEndpoints,
|
||||
"skip_mirror_endpoints": skipMirrorEndpoints,
|
||||
"preferred_source": preferredSource,
|
||||
"internal_paths": internalPaths,
|
||||
"redact_logs": redact,
|
||||
"log_bodies": logBody,
|
||||
"record_interactions": record,
|
||||
"shortcuts": shortcuts,
|
||||
"spotify_configured": spotifyConfigured,
|
||||
"spotify_client_id": spotifyClientID,
|
||||
"spotify_client_secret": spotifyClientSecret,
|
||||
"spotify_redirect_uri": spotifyRedirectURI,
|
||||
"amazon_configured": amazonConfigured,
|
||||
"amazon_client_id": amazonClientID,
|
||||
"amazon_client_secret": amazonClientSecret,
|
||||
"amazon_redirect_uri": amazonRedirectURI,
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -410,6 +410,11 @@ func TestRemoveDevice(t *testing.T) {
|
||||
type mockSSH struct {
|
||||
host string
|
||||
runCount int
|
||||
|
||||
// uploaded mirrors UploadContent calls so that a subsequent
|
||||
// `cat <path>` (notably the tmp-readback step in
|
||||
// TrustCACertFromBytes) returns what we just wrote there.
|
||||
uploaded map[string][]byte
|
||||
}
|
||||
|
||||
func (m *mockSSH) Run(command string) (string, error) {
|
||||
@@ -427,7 +432,21 @@ func (m *mockSSH) Run(command string) (string, error) {
|
||||
if strings.HasPrefix(command, "grep -F") {
|
||||
return "matched", nil // CA trusted
|
||||
}
|
||||
if strings.HasPrefix(command, "cat ") {
|
||||
path := strings.TrimPrefix(command, "cat ")
|
||||
if body, ok := m.uploaded[path]; ok {
|
||||
return string(body), nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *mockSSH) UploadContent(content []byte, remotePath string) error { return nil }
|
||||
func (m *mockSSH) UploadContent(content []byte, remotePath string) error {
|
||||
if m.uploaded == nil {
|
||||
m.uploaded = make(map[string][]byte)
|
||||
}
|
||||
|
||||
m.uploaded[remotePath] = append([]byte(nil), content...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// TestIssue218_OrionStationResolvesPresetStreamURL closes the loop on
|
||||
// the issue #218 regression: it takes the exact preset location URL the
|
||||
// reporter pasted, follows it against the real router, and asserts the
|
||||
// returned BmxPlaybackResponse exposes the speaker-playable streamUrl.
|
||||
//
|
||||
// Pairs with pkg/service/setup/issue218_regression_test.go, which
|
||||
// verifies the preset survives device sync verbatim. Together they
|
||||
// prove that:
|
||||
//
|
||||
// 1. The sync step preserves the cloud URL embedded in
|
||||
// LOCAL_INTERNET_RADIO presets.
|
||||
// 2. Hitting that URL against AfterTouch's router resolves it to the
|
||||
// speaker-playable stream — no rewrite required on the persisted
|
||||
// preset itself.
|
||||
//
|
||||
// Before commit f3a4658, this test would have 404'd: the orion routes
|
||||
// were wrongly nested under `/bmx/` while the BMX registry advertises
|
||||
// the un-prefixed path. See the matching doc-comment on
|
||||
// HandleOrionPlayback for the protocol detail.
|
||||
func TestIssue218_OrionStationResolvesPresetStreamURL(t *testing.T) {
|
||||
// Verbatim from pkg/service/setup/testdata/issue218/presets.xml's
|
||||
// ContentItem `location` attribute (issue #218 body). Decoded
|
||||
// query payload is:
|
||||
// {"name":"OPB","imageUrl":"","streamUrl":"http://ais-sa3.cdnstream1.com/2440_128.aac"}
|
||||
const presetLocation = "https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/station?data=eyJuYW1lIjoiT1BCIiwiaW1hZ2VVcmwiOiIiLCJzdHJlYW1VcmwiOiJodHRwOi8vYWlzLXNhMy5jZG5zdHJlYW0xLmNvbS8yNDQwXzEyOC5hYWMifQ%3D%3D"
|
||||
|
||||
const wantStreamURL = "http://ais-sa3.cdnstream1.com/2440_128.aac"
|
||||
|
||||
// Sanity: the base64 payload really does encode wantStreamURL.
|
||||
// If the fixture ever diverges from this expectation the test
|
||||
// would silently keep passing on whatever the new payload says;
|
||||
// pin it explicitly.
|
||||
parsedLocation, err := url.Parse(presetLocation)
|
||||
if err != nil {
|
||||
t.Fatalf("parse preset location: %v", err)
|
||||
}
|
||||
|
||||
data := parsedLocation.Query().Get("data")
|
||||
if data == "" {
|
||||
t.Fatalf("preset location has no `data` query param: %s", presetLocation)
|
||||
}
|
||||
|
||||
decoded, err := base64.URLEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
// Some captures use RawURLEncoding (no padding); fall back.
|
||||
decoded, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(data, "="))
|
||||
if err != nil {
|
||||
t.Fatalf("decode data blob: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.Contains(string(decoded), wantStreamURL) {
|
||||
t.Fatalf("fixture data does not encode the expected streamUrl.\ndecoded:\n%s\nwant substring:\n%s",
|
||||
decoded, wantStreamURL)
|
||||
}
|
||||
|
||||
// Drive the real router. Use only the path+query from the preset
|
||||
// URL — host is what DNS interception or URL-flip would have
|
||||
// substituted at runtime, not what the test server bound to.
|
||||
r, _ := setupRouter("http://localhost:8001", nil)
|
||||
ts := httptest.NewServer(r)
|
||||
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
resolved := ts.URL + parsedLocation.RequestURI()
|
||||
|
||||
// Real speakers retrieve an orion token from
|
||||
// POST /core02/svc-bmx-adapter-orion/prod/orion/token before they
|
||||
// ever follow a LOCAL_INTERNET_RADIO preset; the playback handler
|
||||
// rejects an empty Authorization header for parity with the other
|
||||
// BMX playback routes. Use a sentinel Bearer token to match that
|
||||
// shape — HandleOrionPlayback doesn't validate the token contents,
|
||||
// only its presence.
|
||||
req, _ := http.NewRequest("GET", resolved, nil)
|
||||
req.Header.Set("Authorization", "Bearer mock-token")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", resolved, err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("GET %s → %d, want 200; body:\n%s", resolved, resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var got models.BmxPlaybackResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
if got.Audio.StreamUrl != wantStreamURL {
|
||||
t.Errorf("audio.streamUrl = %q, want %q", got.Audio.StreamUrl, wantStreamURL)
|
||||
}
|
||||
|
||||
if got.Name != "OPB" {
|
||||
t.Errorf("name = %q, want %q", got.Name, "OPB")
|
||||
}
|
||||
|
||||
if got.StreamType != "liveRadio" {
|
||||
t.Errorf("streamType = %q, want %q", got.StreamType, "liveRadio")
|
||||
}
|
||||
|
||||
// The streams array should mirror the top-level streamUrl —
|
||||
// PlayCustomStream sets both for parity with what real Bose emits.
|
||||
if len(got.Audio.Streams) == 0 {
|
||||
t.Errorf("audio.streams empty, want at least one entry with streamUrl=%q", wantStreamURL)
|
||||
} else if got.Audio.Streams[0].StreamUrl != wantStreamURL {
|
||||
t.Errorf("audio.streams[0].streamUrl = %q, want %q", got.Audio.Streams[0].StreamUrl, wantStreamURL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// TestIssue285_RenamePutAcceptedAndPersisted reproduces the rename
|
||||
// loop documented in issue #285:
|
||||
//
|
||||
// https://github.com/gesellix/Bose-SoundTouch/issues/285
|
||||
//
|
||||
// When a user renames an ST10 via the Bose App or via
|
||||
// `soundtouch-cli name set`, the speaker fires PUT
|
||||
// /streaming/account/{accountID}/device/{deviceID} with a body of
|
||||
// the form:
|
||||
//
|
||||
// <device deviceid="…"><name>NEW</name><macaddress>…</macaddress></device>
|
||||
//
|
||||
// Before this commit the router only registered POST for that path;
|
||||
// PUT fell through to the chi router's default handling and the
|
||||
// speaker observed HTTP 502 (captured verbatim in
|
||||
// _/i285/Rename.log:38: "SimpleURLFetcher: retry needed, Curl 0,
|
||||
// http 502, retries remaining 0"). The speaker retried in a loop
|
||||
// and the Bose App showed the rename spinning indefinitely.
|
||||
//
|
||||
// The fixture at testdata/issue285/rename_request.xml is the exact
|
||||
// payload from the log (line 36) — `deviceid="884AEAEEBD27"`,
|
||||
// `<name>Wohnzimmer SB</name>`. The test:
|
||||
//
|
||||
// 1. Pre-seeds the datastore with a device record under the
|
||||
// reporter's accountID + deviceID so the PUT is updating, not
|
||||
// creating.
|
||||
// 2. Replays the rename PUT.
|
||||
// 3. Asserts:
|
||||
// - HTTP 200 (NOT 201; this is an update, not a create — speakers
|
||||
// observed 502 before, so any 2xx is the headline fix, but
|
||||
// pinning 200 protects against accidentally returning 201
|
||||
// which would change the Location-header contract).
|
||||
// - Response body carries the new name verbatim.
|
||||
// - Persisted Sources/DeviceInfo on disk reflects the new name.
|
||||
//
|
||||
// When future work decides to preserve `createdOn` across updates
|
||||
// (currently AddDeviceToAccount rewrites both timestamps), update
|
||||
// the test to also assert that — the rename request from the log
|
||||
// does NOT carry a createdOn, so any value our marge response
|
||||
// emits is purely our choice and should be stable.
|
||||
func TestIssue285_RenamePutAcceptedAndPersisted(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "issue285-")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir temp: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
const (
|
||||
accountID = "3981561"
|
||||
deviceID = "884AEAEEBD27"
|
||||
oldName = "Wohnzimmer"
|
||||
newName = "Wohnzimmer SB"
|
||||
preExistingIP = "192.168.0.109"
|
||||
preExistingPaired = "2017-02-07T11:13:03.000+00:00"
|
||||
)
|
||||
|
||||
// 1. Seed datastore with the device under its original name and
|
||||
// a known pre-existing first-paired timestamp. The pre-existing
|
||||
// data models a long-paired device the user is now renaming —
|
||||
// CreatedOn must survive the PUT (real Bose preserves it
|
||||
// across renames; see parity capture at
|
||||
// data/parity_mismatches/1771797308__streaming_account_3230304_device_A81B6A536A98.json).
|
||||
if err := ds.SaveDeviceInfo(accountID, deviceID, &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: accountID,
|
||||
Name: oldName,
|
||||
IPAddress: preExistingIP,
|
||||
CreatedOn: preExistingPaired,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed datastore: %v", err)
|
||||
}
|
||||
|
||||
// 2. Spin up the router and replay the captured rename PUT.
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
body, err := os.ReadFile(filepath.Join("testdata", "issue285", "rename_request.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
|
||||
// Sanity-check the fixture before trusting any downstream
|
||||
// assertion against it.
|
||||
if !bytes.Contains(body, []byte(`deviceid="`+deviceID+`"`)) {
|
||||
t.Fatalf("fixture missing expected deviceid=%q; got:\n%s", deviceID, body)
|
||||
}
|
||||
|
||||
if !bytes.Contains(body, []byte(`<name>`+newName+`</name>`)) {
|
||||
t.Fatalf("fixture missing expected new name %q; got:\n%s", newName, body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
ts.URL+"/streaming/account/"+accountID+"/device/"+deviceID,
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("PUT: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// 3. Headline assertion: the speaker observed 502 before — any
|
||||
// 2xx fixes the loop. Pin 200 specifically so we don't drift
|
||||
// into 201/Created (which would change the Location-header
|
||||
// contract POST gets).
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("PUT status = %d, want 200; body:\n%s", resp.StatusCode, respBody)
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
|
||||
// Response shape: <device …><name>NEW</name>…</device>
|
||||
if !bytes.Contains(respBody, []byte(`deviceid="`+deviceID+`"`)) {
|
||||
t.Errorf("response missing deviceid=%q; body:\n%s", deviceID, respBody)
|
||||
}
|
||||
|
||||
if !bytes.Contains(respBody, []byte(`<name>`+newName+`</name>`)) {
|
||||
t.Errorf("response missing new name %q; body:\n%s", newName, respBody)
|
||||
}
|
||||
|
||||
if strings.Contains(string(respBody), `<name>`+oldName+`</name>`) {
|
||||
t.Errorf("response still carries old name %q; body:\n%s", oldName, respBody)
|
||||
}
|
||||
|
||||
// Parity assertion: the pre-existing first-paired CreatedOn
|
||||
// must survive the rename. This is the load-bearing fix versus
|
||||
// the prior behaviour that rewrote `now()` on every PUT, and
|
||||
// matches what real Bose's pre-shutdown 200 OK responses
|
||||
// carried (see the parity capture referenced above).
|
||||
if !bytes.Contains(respBody, []byte(`<createdOn>`+preExistingPaired+`</createdOn>`)) {
|
||||
t.Errorf("response did not preserve pre-existing CreatedOn %q; body:\n%s", preExistingPaired, respBody)
|
||||
}
|
||||
|
||||
// Parity assertion: the pre-existing IP address must survive
|
||||
// the rename. The request body doesn't carry an `<ipaddress>`,
|
||||
// so the datastore merge has to inject what was already on
|
||||
// disk rather than writing back empty.
|
||||
if !bytes.Contains(respBody, []byte(`<ipaddress>`+preExistingIP+`</ipaddress>`)) {
|
||||
t.Errorf("response did not preserve pre-existing IPAddress %q; body:\n%s", preExistingIP, respBody)
|
||||
}
|
||||
|
||||
// Parity assertion: UpdatedOn refreshes. Don't pin the exact
|
||||
// value — it's "now()" — but assert it's present and
|
||||
// non-empty.
|
||||
if !bytes.Contains(respBody, []byte(`<updatedOn>`)) ||
|
||||
bytes.Contains(respBody, []byte(`<updatedOn></updatedOn>`)) {
|
||||
t.Errorf("response missing or empty <updatedOn>; body:\n%s", respBody)
|
||||
}
|
||||
|
||||
// 4. Persistence assertion: the datastore now reflects the new
|
||||
// name AND keeps the original CreatedOn. This is what the
|
||||
// Bose App reads back on its next /streaming/account/.../full
|
||||
// poll, which is what closes the visible rename loop.
|
||||
persisted, err := ds.GetDeviceInfo(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("read persisted device info: %v", err)
|
||||
}
|
||||
|
||||
if persisted.Name != newName {
|
||||
t.Errorf("persisted Name = %q, want %q", persisted.Name, newName)
|
||||
}
|
||||
|
||||
if persisted.CreatedOn != preExistingPaired {
|
||||
t.Errorf("persisted CreatedOn = %q, want %q (preserved across rename)", persisted.CreatedOn, preExistingPaired)
|
||||
}
|
||||
|
||||
if persisted.IPAddress != preExistingIP {
|
||||
t.Errorf("persisted IPAddress = %q, want %q (preserved across rename)", persisted.IPAddress, preExistingIP)
|
||||
}
|
||||
|
||||
if persisted.UpdatedOn == "" {
|
||||
t.Errorf("persisted UpdatedOn is empty; want a fresh timestamp from the rename")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue285_NewDeviceGetsRemoteAddrAndFreshTimestamps covers the
|
||||
// "first-time registration" path on a PUT (which can happen if the
|
||||
// speaker emits a rename before AfterTouch has ever heard of it).
|
||||
// With no pre-existing datastore record:
|
||||
//
|
||||
// - CreatedOn must be a fresh timestamp (no record to preserve).
|
||||
// - IPAddress must come from r.RemoteAddr (the inbound connection)
|
||||
// since the request body doesn't carry one.
|
||||
// - UpdatedOn must be the same fresh timestamp.
|
||||
//
|
||||
// Pairs with the parity-preservation assertions in the main test:
|
||||
// existing records win, but new records seed sensibly instead of
|
||||
// landing with empty CreatedOn / IPAddress.
|
||||
func TestIssue285_NewDeviceGetsRemoteAddrAndFreshTimestamps(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "issue285-new-")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir temp: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
const (
|
||||
accountID = "1111111"
|
||||
deviceID = "A81B6A536A98"
|
||||
newName = "Sound Machinechen"
|
||||
)
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
body := []byte(`<?xml version="1.0" encoding="UTF-8" ?>` +
|
||||
`<device deviceid="` + deviceID + `"><name>` + newName + `</name><macaddress>` + deviceID + `</macaddress></device>`)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
ts.URL+"/streaming/account/"+accountID+"/device/"+deviceID,
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("PUT: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("PUT status = %d, want 200; body:\n%s", resp.StatusCode, respBody)
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
|
||||
// CreatedOn present and non-empty (will be "now()" since no
|
||||
// prior record existed).
|
||||
if !bytes.Contains(respBody, []byte(`<createdOn>`)) ||
|
||||
bytes.Contains(respBody, []byte(`<createdOn></createdOn>`)) {
|
||||
t.Errorf("first-registration response missing CreatedOn; body:\n%s", respBody)
|
||||
}
|
||||
|
||||
// IPAddress should be the httptest connection's remote host
|
||||
// (127.0.0.1) since the body didn't carry one and there was
|
||||
// no existing record to preserve from.
|
||||
if !bytes.Contains(respBody, []byte(`<ipaddress>127.0.0.1</ipaddress>`)) {
|
||||
t.Errorf("first-registration response missing IPAddress from RemoteAddr; body:\n%s", respBody)
|
||||
}
|
||||
|
||||
// Persistence: CreatedOn and IPAddress on disk too.
|
||||
persisted, err := ds.GetDeviceInfo(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("read persisted device info: %v", err)
|
||||
}
|
||||
|
||||
if persisted.CreatedOn == "" {
|
||||
t.Errorf("persisted CreatedOn is empty for new device; want a fresh timestamp")
|
||||
}
|
||||
|
||||
if persisted.IPAddress != "127.0.0.1" {
|
||||
t.Errorf("persisted IPAddress = %q, want %q (from RemoteAddr)", persisted.IPAddress, "127.0.0.1")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue285_RenamePutRejectsMismatchedDeviceID pins the safety
|
||||
// check: if the speaker (or a bug elsewhere) ever sends a PUT with
|
||||
// a body whose `deviceid="…"` doesn't match the URL's `{device}`
|
||||
// segment, we refuse with 400 rather than silently re-key the
|
||||
// persisted record under the wrong account/device.
|
||||
func TestIssue285_RenamePutRejectsMismatchedDeviceID(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "issue285-mismatch-")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir temp: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
const urlDeviceID = "884AEAEEBD27"
|
||||
|
||||
// Body claims a different deviceID than the URL.
|
||||
body := []byte(`<?xml version="1.0" encoding="UTF-8" ?>` +
|
||||
`<device deviceid="DEADBEEFCAFE"><name>Rogue</name><macaddress>DEADBEEFCAFE</macaddress></device>`)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
ts.URL+"/streaming/account/3981561/device/"+urlDeviceID,
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("PUT: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("PUT status = %d, want 400; body:\n%s", resp.StatusCode, respBody)
|
||||
}
|
||||
|
||||
// Mismatched body must be rejected *before* the upsert runs —
|
||||
// otherwise the datastore ends up with a row keyed on the body's
|
||||
// deviceID even though we return 400. Verify by reading both keys.
|
||||
if got, _ := ds.GetDeviceInfo("3981561", "DEADBEEFCAFE"); got != nil {
|
||||
t.Fatalf("body deviceID DEADBEEFCAFE was persisted despite 400 response: %+v", got)
|
||||
}
|
||||
|
||||
if got, _ := ds.GetDeviceInfo("3981561", urlDeviceID); got != nil {
|
||||
t.Fatalf("URL deviceID %s was persisted despite 400 response: %+v", urlDeviceID, got)
|
||||
}
|
||||
}
|
||||
@@ -32,9 +32,14 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Get("/tunein/v1/navigate", server.HandleTuneInNavigate)
|
||||
r.Get("/tunein/v1/navigate/*", server.HandleTuneInNavigate)
|
||||
r.Get("/tunein/v1/search", server.HandleTuneInSearch)
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
})
|
||||
|
||||
// Orion lives at the top level — see the matching note in
|
||||
// cmd/soundtouch-service/main.go. Mirrored here so the test router
|
||||
// exercises the same paths the production router does.
|
||||
r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken)
|
||||
r.Get("/core02/svc-bmx-adapter-orion/prod/orion/station", server.HandleOrionPlayback)
|
||||
|
||||
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
|
||||
|
||||
streamingRoutes := func(r chi.Router) {
|
||||
@@ -42,6 +47,8 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Route("/account/{account}/device", func(r chi.Router) {
|
||||
r.Post("/", server.HandleMargeAddDevice)
|
||||
r.Post("/{device}", server.HandleMargeAddDevice)
|
||||
// Rename PUT — mirrors the production router. Issue #285.
|
||||
r.Put("/{device}", server.HandleMargeUpdateDevice)
|
||||
})
|
||||
r.Get("/account/{account}/device/{device}/recent", server.HandleMargeRecents)
|
||||
r.Post("/account/{account}/device/{device}/recent", server.HandleMargeAddRecent)
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Probe443Result captures the outcome of probing a host on :443.
|
||||
// Skipped is true when the running HTTPS listener is already on :443
|
||||
// (in which case the listener itself is the proof of reachability).
|
||||
type Probe443Result struct {
|
||||
Skipped bool
|
||||
Localhost ProbeOutcome
|
||||
LAN ProbeOutcome
|
||||
LANHost string
|
||||
}
|
||||
|
||||
// ProbeOutcome describes a single TCP-connect probe. Exactly one of
|
||||
// Reachable/Error is meaningful: Reachable=true means the dial succeeded,
|
||||
// otherwise Error holds the dial error string.
|
||||
type ProbeOutcome struct {
|
||||
Reachable bool
|
||||
Error string
|
||||
}
|
||||
|
||||
// ProbeDialTimeoutStartup is the per-attempt TCP dial timeout used by the
|
||||
// startup preflight, where we can afford to wait a beat for a slow LAN.
|
||||
const ProbeDialTimeoutStartup = 2 * time.Second
|
||||
|
||||
// ProbeDialTimeoutInline is the per-attempt TCP dial timeout used by the
|
||||
// settings HTTP handler, where a user is blocking on the response.
|
||||
const ProbeDialTimeoutInline = 500 * time.Millisecond
|
||||
|
||||
// ProbeTCP attempts a TCP connection to host:port within timeout. It returns
|
||||
// nil on success; an error otherwise. The connection is closed immediately —
|
||||
// we only care whether *something* would answer where a speaker knocks.
|
||||
func ProbeTCP(host string, port int, timeout time.Duration) error {
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check443Reachability probes both localhost:443 and the LAN-facing IP that
|
||||
// DNS would hand out for serverURL on :443. It is intended to surface the
|
||||
// most common AfterTouch misconfiguration: HTTPS listener on :8443 with no
|
||||
// routing in place from :443 (speakers connect to implicit :443 and see
|
||||
// Curl 7 / connection refused with nothing reaching AfterTouch).
|
||||
//
|
||||
// If httpsListenerPort is already 443, both probes are skipped — the running
|
||||
// listener proves :443 is reachable.
|
||||
//
|
||||
// lanResolver is the function used to translate serverURL into a LAN IP; in
|
||||
// production this is Server.resolveServerURLIP. It is injected so this can
|
||||
// be tested without a full Server.
|
||||
func Check443Reachability(
|
||||
httpsListenerPort int,
|
||||
serverURL string,
|
||||
lanResolver func(string) (string, error),
|
||||
timeout time.Duration,
|
||||
) Probe443Result {
|
||||
if httpsListenerPort == 443 {
|
||||
return Probe443Result{Skipped: true}
|
||||
}
|
||||
|
||||
res := Probe443Result{}
|
||||
|
||||
if err := ProbeTCP("127.0.0.1", 443, timeout); err != nil {
|
||||
res.Localhost.Error = err.Error()
|
||||
} else {
|
||||
res.Localhost.Reachable = true
|
||||
}
|
||||
|
||||
lanIP, resolveErr := lanResolver(serverURL)
|
||||
if resolveErr != nil {
|
||||
res.LAN.Error = "cannot resolve LAN target: " + resolveErr.Error()
|
||||
return res
|
||||
}
|
||||
|
||||
res.LANHost = lanIP
|
||||
|
||||
if err := ProbeTCP(lanIP, 443, timeout); err != nil {
|
||||
res.LAN.Error = err.Error()
|
||||
} else {
|
||||
res.LAN.Reachable = true
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// PortFromHTTPSServerURL extracts the numeric port from httpsServerURL. It
|
||||
// returns 0 if the URL is empty, malformed, or has no explicit port — in
|
||||
// that case the caller cannot make a determination about :443 and should
|
||||
// treat the result as "unknown" rather than "definitely not 443".
|
||||
func PortFromHTTPSServerURL(httpsServerURL string) int {
|
||||
if httpsServerURL == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
u, err := url.Parse(httpsServerURL)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
portStr := u.Port()
|
||||
if portStr == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return port
|
||||
}
|
||||
|
||||
// FormatPreflightGuidance returns a multi-line, human-readable warning
|
||||
// summarising a failing Probe443Result, with actionable next steps. The
|
||||
// returned string ends without a trailing newline so callers may use it
|
||||
// with log.Print or log.Printf as they prefer.
|
||||
func FormatPreflightGuidance(httpsListenerPort int, res Probe443Result) string {
|
||||
if res.Skipped {
|
||||
return ""
|
||||
}
|
||||
|
||||
if res.Localhost.Reachable && res.LAN.Reachable {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
fmt.Sprintf("[WARN] HTTPS pre-flight: speakers connect to :443 but AfterTouch listens on :%d.", httpsListenerPort),
|
||||
}
|
||||
|
||||
if res.Localhost.Reachable {
|
||||
lines = append(lines, " - localhost:443: reachable ✓")
|
||||
} else {
|
||||
lines = append(lines, " - localhost:443: "+res.Localhost.Error)
|
||||
}
|
||||
|
||||
switch {
|
||||
case res.LAN.Reachable:
|
||||
lines = append(lines, fmt.Sprintf(" - %s:443 (LAN): reachable ✓", res.LANHost))
|
||||
case res.LANHost != "":
|
||||
lines = append(lines, fmt.Sprintf(" - %s:443 (LAN): %s", res.LANHost, res.LAN.Error))
|
||||
default:
|
||||
lines = append(lines, " - LAN: "+res.LAN.Error)
|
||||
}
|
||||
|
||||
lines = append(lines,
|
||||
" Speakers will fail with Curl 7 / connection refused until :443 is routed to AfterTouch. Options:",
|
||||
" 1. iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port "+strconv.Itoa(httpsListenerPort),
|
||||
" 2. setcap cap_net_bind_service=+ep <binary> and pass --https-port=443",
|
||||
" 3. reverse proxy (nginx/caddy) terminating TLS on :443",
|
||||
" See docs/guides/HTTPS-SETUP.md for details.",
|
||||
)
|
||||
|
||||
out := ""
|
||||
|
||||
for i, l := range lines {
|
||||
if i > 0 {
|
||||
out += "\n"
|
||||
}
|
||||
|
||||
out += l
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProbeTCP_OpenPortSucceeds(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start listener: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
if err := ProbeTCP("127.0.0.1", port, 500*time.Millisecond); err != nil {
|
||||
t.Errorf("expected probe of open port to succeed, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeTCP_ClosedPortFails(t *testing.T) {
|
||||
// Bind, capture port, close — leaves the port verifiably unbound.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start listener: %v", err)
|
||||
}
|
||||
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
_ = ln.Close()
|
||||
|
||||
if err := ProbeTCP("127.0.0.1", port, 500*time.Millisecond); err == nil {
|
||||
t.Errorf("expected probe of closed port to fail, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck443Reachability_SkipsWhenListenerOn443(t *testing.T) {
|
||||
res := Check443Reachability(443, "http://example.test:8000", func(string) (string, error) {
|
||||
t.Errorf("resolver should not be called when listener is on :443")
|
||||
return "", nil
|
||||
}, 100*time.Millisecond)
|
||||
|
||||
if !res.Skipped {
|
||||
t.Errorf("expected Skipped=true when httpsListenerPort=443, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck443Reachability_ReportsResolverError(t *testing.T) {
|
||||
res := Check443Reachability(8443, "http://broken", func(string) (string, error) {
|
||||
return "", errResolve("no DNS")
|
||||
}, 100*time.Millisecond)
|
||||
|
||||
if res.Skipped {
|
||||
t.Errorf("expected Skipped=false, got true")
|
||||
}
|
||||
|
||||
if res.LAN.Reachable {
|
||||
t.Errorf("expected LAN.Reachable=false, got true")
|
||||
}
|
||||
|
||||
if !strings.Contains(res.LAN.Error, "cannot resolve LAN target") {
|
||||
t.Errorf("expected LAN.Error to wrap resolver failure, got %q", res.LAN.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortFromHTTPSServerURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want int
|
||||
}{
|
||||
{"", 0},
|
||||
{"https://example.test:8443", 8443},
|
||||
{"https://example.test:443", 443},
|
||||
{"https://example.test", 0},
|
||||
{":::not a url", 0},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := PortFromHTTPSServerURL(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("PortFromHTTPSServerURL(%q) = %d, want %d", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPreflightGuidance_SkippedAndAllOK(t *testing.T) {
|
||||
if FormatPreflightGuidance(443, Probe443Result{Skipped: true}) != "" {
|
||||
t.Errorf("expected empty guidance when skipped")
|
||||
}
|
||||
|
||||
bothOK := Probe443Result{
|
||||
Localhost: ProbeOutcome{Reachable: true},
|
||||
LAN: ProbeOutcome{Reachable: true},
|
||||
LANHost: "10.0.0.1",
|
||||
}
|
||||
if FormatPreflightGuidance(8443, bothOK) != "" {
|
||||
t.Errorf("expected empty guidance when both probes succeed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPreflightGuidance_BothFailMentionsRedirectPort(t *testing.T) {
|
||||
res := Probe443Result{
|
||||
Localhost: ProbeOutcome{Error: "connection refused"},
|
||||
LAN: ProbeOutcome{Error: "connection refused"},
|
||||
LANHost: "192.168.1.151",
|
||||
}
|
||||
|
||||
out := FormatPreflightGuidance(8443, res)
|
||||
if !strings.Contains(out, "--to-port 8443") {
|
||||
t.Errorf("guidance must reference configured listener port for iptables, got: %s", out)
|
||||
}
|
||||
|
||||
if !strings.Contains(out, "192.168.1.151:443") {
|
||||
t.Errorf("guidance must mention probed LAN host, got: %s", out)
|
||||
}
|
||||
|
||||
if !strings.Contains(out, "[WARN]") {
|
||||
t.Errorf("guidance must be marked as a warning, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
type errResolve string
|
||||
|
||||
func (e errResolve) Error() string { return string(e) }
|
||||
|
||||
func TestCheck443Reachability_LANProbeMatchesListenerOutcome(t *testing.T) {
|
||||
// Spin up a listener on a random port and use that port via resolver
|
||||
// trickery: we point the LAN host at 127.0.0.1 and rely on the fact that
|
||||
// nothing answers on :443 in test environments. The point of this test
|
||||
// is to lock in the result-shape: when localhost:443 is closed (the
|
||||
// default in CI), the function still returns a well-formed result and
|
||||
// reports the resolved LAN host.
|
||||
res := Check443Reachability(8443, "http://1.2.3.4:8000", func(string) (string, error) {
|
||||
return "1.2.3.4", nil
|
||||
}, 200*time.Millisecond)
|
||||
|
||||
if res.Skipped {
|
||||
t.Fatalf("expected Skipped=false, got true")
|
||||
}
|
||||
|
||||
if res.LANHost != "1.2.3.4" {
|
||||
t.Errorf("expected LANHost=1.2.3.4, got %q", res.LANHost)
|
||||
}
|
||||
|
||||
// In any sane CI environment nothing is listening on :443, so both
|
||||
// probes should report errors. We don't assert the exact error string
|
||||
// (varies by OS) but we do assert it's populated.
|
||||
if res.LAN.Reachable {
|
||||
t.Errorf("did not expect LAN:443 to be reachable in test env")
|
||||
}
|
||||
|
||||
if res.LAN.Error == "" {
|
||||
t.Errorf("expected LAN.Error to be populated when unreachable")
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,13 @@ func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveServerURLIPForPreflight is an exported wrapper around resolveServerURLIP
|
||||
// so callers outside the package (e.g. the service startup pre-flight) can
|
||||
// reuse the same resolution path the DNS server uses.
|
||||
func (s *Server) ResolveServerURLIPForPreflight(serverURL string) (string, error) {
|
||||
return s.resolveServerURLIP(serverURL)
|
||||
}
|
||||
|
||||
// resolveServerURLIP returns the IP that the DNS server would hand out as the
|
||||
// intercept answer for the given server URL. An empty URL, empty hostname, or a
|
||||
// hostname that cannot be resolved to an IP is reported as an error so callers
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?><device deviceid="884AEAEEBD27"><name>Wohnzimmer SB</name><macaddress>884AEAEEBD27</macaddress></device>
|
||||
@@ -160,6 +160,7 @@
|
||||
<input type="text" id="target-domain" placeholder="http://192.168.x.x:8000" style="width: 300px"/>
|
||||
<span style="font-size: 0.8em; color: #666">(Standard services URL)</span>
|
||||
<div id="target-domain-resolved" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div>
|
||||
<div id="https-443-status" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px">
|
||||
<strong>Device Discovery:</strong>
|
||||
|
||||
@@ -1,3 +1,67 @@
|
||||
// FAST_ERROR_MS is the timing threshold used to distinguish "no listener
|
||||
// on :443" (very fast browser error, usually TCP RST) from "something
|
||||
// answered TCP, TLS handshake failed because of untrusted cert" (slower
|
||||
// error). The exact cutoff is fuzzy and varies by browser/network, but
|
||||
// the gap between the two cases is large enough (single-digit ms vs.
|
||||
// 100+ ms) that this works as a heuristic. We don't expose milliseconds
|
||||
// to the user — they'd be misleading without context.
|
||||
const FAST_ERROR_MS = 150;
|
||||
|
||||
async function probeBrowser443(lanHost, listenerPort, statusEl, serverLocalhostOK, serverLanOK) {
|
||||
const line = document.createElement("div");
|
||||
line.style.fontSize = "0.85em";
|
||||
line.style.marginTop = "2px";
|
||||
line.style.color = "#666";
|
||||
line.innerText = "⏱ Checking from your browser too…";
|
||||
statusEl.appendChild(line);
|
||||
|
||||
const start = performance.now();
|
||||
let outcome;
|
||||
try {
|
||||
// mode:"no-cors" lets the request go on the wire even though the response
|
||||
// would be opaque. We only care about success-or-fail and timing — not
|
||||
// the response body, which we can't read anyway with an untrusted cert.
|
||||
await fetch("https://" + lanHost + ":443/", {
|
||||
mode: "no-cors",
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
outcome = { reached: true, elapsed: performance.now() - start };
|
||||
} catch (e) {
|
||||
outcome = { reached: false, elapsed: performance.now() - start, err: e };
|
||||
}
|
||||
|
||||
let msg;
|
||||
let color;
|
||||
if (outcome.reached) {
|
||||
color = "#2e7d32";
|
||||
msg = "✅ Your browser also reaches <code>:443</code> on <code>" + lanHost + "</code>.";
|
||||
} else if (outcome.elapsed >= FAST_ERROR_MS) {
|
||||
color = "#2e7d32";
|
||||
msg = "✅ Your browser reached <code>:" + lanHost + ":443</code> — the failure that follows is the expected " +
|
||||
"untrusted-CA error, not a missing listener.";
|
||||
} else {
|
||||
color = "#c62828";
|
||||
msg = "❌ Your browser sees no listener on <code>" + lanHost + ":443</code> " +
|
||||
"(fast error, likely connection refused).";
|
||||
}
|
||||
|
||||
// Hint when server and browser disagree — that almost always means NAT,
|
||||
// split-horizon DNS, or a host firewall sitting between AfterTouch and
|
||||
// the speaker. Worth pointing out because it's invisible to the server.
|
||||
const browserSees443 = outcome.reached || outcome.elapsed >= FAST_ERROR_MS;
|
||||
if (serverLanOK && !browserSees443) {
|
||||
msg += " <em>(Server sees :443 but your browser doesn't — check intermediate firewalls / split-horizon DNS.)</em>";
|
||||
color = "#c62828";
|
||||
} else if (!serverLanOK && browserSees443) {
|
||||
msg += " <em>(Your browser reaches :443 but the AfterTouch host can't — likely a host-firewall rule on the AfterTouch machine itself.)</em>";
|
||||
color = "#c62828";
|
||||
}
|
||||
|
||||
line.style.color = color;
|
||||
line.innerHTML = msg;
|
||||
}
|
||||
|
||||
async function fetchSpotifyStatus() {
|
||||
try {
|
||||
const settingsResponse = await fetch("/setup/settings");
|
||||
@@ -139,6 +203,55 @@ async function fetchSettings() {
|
||||
resolved.innerText = "";
|
||||
}
|
||||
}
|
||||
|
||||
const port443 = document.getElementById("https-443-status");
|
||||
if (port443) {
|
||||
// The :443 check only applies to the DNS-migration path. Hide the row
|
||||
// entirely when AfterTouch's DNS interception is off — those users are
|
||||
// either using SDK overrides (port-explicit URLs) or external DNS
|
||||
// interception (in which case they can read /setup/settings JSON
|
||||
// directly if they want the result).
|
||||
if (!settings.dns_enabled) {
|
||||
port443.innerHTML = "";
|
||||
} else if (settings.https_443_check_skipped) {
|
||||
port443.style.color = "#2e7d32";
|
||||
port443.innerHTML = "✅ HTTPS listener bound directly to <code>:443</code> — speakers can connect.";
|
||||
} else {
|
||||
const localhostOK = settings.https_443_localhost_reachable;
|
||||
const lanOK = settings.https_443_lan_reachable;
|
||||
const lanHost = settings.https_443_lan_host || "";
|
||||
const listenerPort = settings.https_listener_port || "8443";
|
||||
if (localhostOK && lanOK) {
|
||||
port443.style.color = "#2e7d32";
|
||||
port443.innerHTML = "✅ <code>:443</code> reachable on <code>localhost</code> and <code>" +
|
||||
(lanHost || "LAN address") + "</code> (forwarded to <code>:" + listenerPort + "</code>).";
|
||||
} else {
|
||||
port443.style.color = "#c62828";
|
||||
const details = [];
|
||||
details.push("localhost:443 " +
|
||||
(localhostOK ? "✓" : "❌ " + (settings.https_443_localhost_error || "unreachable")));
|
||||
details.push((lanHost || "LAN") + ":443 " +
|
||||
(lanOK ? "✓" : "❌ " + (settings.https_443_lan_error || "unreachable")));
|
||||
port443.innerHTML = "❌ Speakers connect to <code>:443</code> but AfterTouch listens on <code>:" +
|
||||
listenerPort + "</code>. " + details.join(" · ") +
|
||||
". Set up iptables / setcap / reverse proxy — see " +
|
||||
"<a href=\"https://github.com/gesellix/Bose-SoundTouch/blob/main/docs/guides/HTTPS-SETUP.md\" target=\"_blank\">HTTPS-SETUP.md</a>.";
|
||||
}
|
||||
|
||||
// Browser-side probe runs in parallel. Mirrors what speakers see from
|
||||
// the LAN; the server-side probe runs from inside AfterTouch's host
|
||||
// and can disagree when there is NAT / split-horizon / a firewall in
|
||||
// between. We can't see TLS-cert vs. TCP-RST from JS, so we fall back
|
||||
// to timing: a fast error suggests no listener; a slower error
|
||||
// suggests the connection got far enough to start TLS, which proves
|
||||
// something is answering. The CA cert is not trusted by the browser
|
||||
// by default, so a clean ✅ resolution is rare — that's fine, the
|
||||
// timing alone is the diagnostic signal.
|
||||
if (lanHost) {
|
||||
probeBrowser443(lanHost, listenerPort, port443, localhostOK, lanOK);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (settings.discovery_interval) {
|
||||
document.getElementById("discovery-interval").value = settings.discovery_interval;
|
||||
}
|
||||
@@ -1696,7 +1809,31 @@ async function updateDeviceInfo(deviceId, ip) {
|
||||
if (deviceIdEl && info.deviceID) deviceIdEl.innerText = info.deviceID;
|
||||
|
||||
const accountIdEl = row.querySelector(".col-accountid");
|
||||
if (accountIdEl && info.margeAccountUUID) accountIdEl.innerText = info.margeAccountUUID;
|
||||
if (accountIdEl) {
|
||||
if (info.margeAccountUUID) {
|
||||
accountIdEl.innerText = info.margeAccountUUID;
|
||||
accountIdEl.style.color = "#666";
|
||||
} else {
|
||||
// Empty <margeAccountUUID/> in /info → speaker is
|
||||
// either factory-reset or never paired. The
|
||||
// Migration tab's wizard already detects this
|
||||
// state and prompts for re-pairing; this badge
|
||||
// surfaces the affordance from the devices list
|
||||
// so users don't have to know to open the
|
||||
// Migration tab cold. See issue #234.
|
||||
accountIdEl.replaceChildren();
|
||||
const badge = document.createElement("a");
|
||||
badge.href = "#";
|
||||
badge.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
prepareMigration(deviceId);
|
||||
};
|
||||
badge.innerText = "⚠ Not paired — re-pair";
|
||||
badge.style.color = "#c62828";
|
||||
badge.title = "Open the Migration tab to re-pair this speaker (factory-reset or never paired).";
|
||||
accountIdEl.appendChild(badge);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch live info for " + ip, error);
|
||||
@@ -2560,8 +2697,11 @@ function readPlanURLOptions() {
|
||||
// validateURL classifies a string as an OK service URL.
|
||||
// Empty value is valid (means "use the canonical default"). Otherwise
|
||||
// the URL must parse, the scheme must be http or https, the hostname
|
||||
// must be non-empty, and we reject "localhost" because the speaker
|
||||
// can't reach this machine via that name.
|
||||
// must be non-empty, and we flag loopback hostnames because they only
|
||||
// reach AfterTouch in the on-device-install case (AfterTouch running
|
||||
// on the speaker itself). For the typical "AfterTouch on a separate
|
||||
// host" deployment, the speaker can't reach loopback on a different
|
||||
// machine, so the URL must be a LAN-reachable IP or hostname.
|
||||
function validateURL(value) {
|
||||
const v = (value || "").trim();
|
||||
if (!v) return {ok: true, error: ""};
|
||||
@@ -2580,7 +2720,7 @@ function validateURL(value) {
|
||||
if (!u.hostname) return {ok: false, error: "hostname is empty"};
|
||||
|
||||
if (u.hostname === "localhost" || u.hostname === "127.0.0.1") {
|
||||
return {ok: false, error: "use the LAN IP/hostname, not localhost — the speaker can't reach this machine via that name"};
|
||||
return {ok: false, error: "loopback URL — speakers can only reach this if AfterTouch is installed on the speaker itself (on-device install). For the typical multi-device setup, use a LAN-reachable IP or hostname."};
|
||||
}
|
||||
|
||||
return {ok: true, error: ""};
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// TestIssue253_PresetsXMLEditPropagatesToMargeResponse documents the
|
||||
// service-side half of issue #253:
|
||||
//
|
||||
// https://github.com/gesellix/Bose-SoundTouch/issues/253
|
||||
//
|
||||
// The reporter edits AfterTouch's persisted Presets.xml on disk and
|
||||
// expects the change to show up on the speaker's :8090/presets. That
|
||||
// propagation chain has three links:
|
||||
//
|
||||
// 1. disk → marge: AfterTouch's marge serves the edited XML when the
|
||||
// speaker GETs /streaming/account/.../device/.../presets (or
|
||||
// /full). This is the link this test exercises.
|
||||
// 2. marge → device: the speaker has to re-fetch (typically nudged by
|
||||
// a /streaming/support/power_on or by a sourcesUpdated
|
||||
// notification — not exercised here, that's a runbook concern).
|
||||
// 3. device → :8090: once the device's local cache updates, its
|
||||
// /presets endpoint reflects. Out of our reach.
|
||||
//
|
||||
// If link (1) is broken — e.g. marge caches the rendered XML between
|
||||
// requests, or ds.GetPresets returns stale data — neither (2) nor (3)
|
||||
// can recover, and the reporter's symptom is inevitable. This test
|
||||
// proves (1) is sound by:
|
||||
//
|
||||
// - Writing testdata/issue253/presets_v1.xml directly into the
|
||||
// datastore (no SavePresets — the reporter is editing on disk).
|
||||
// - Calling PresetsToXML, asserting v1's itemName and location land
|
||||
// in the rendered response.
|
||||
// - Overwriting the file with testdata/issue253/presets_v2.xml.
|
||||
// - Calling PresetsToXML again, asserting v2's itemName and
|
||||
// location land — and v1's are gone.
|
||||
//
|
||||
// If link (1) ever regresses (a caching layer added without
|
||||
// invalidation, a fs handle held open across edits, …), this test
|
||||
// fails on the second assertion. When that happens, fix the
|
||||
// invalidation rather than weakening the test.
|
||||
//
|
||||
// Pattern mirrors recents_sourceproviderid_regression_test.go: write
|
||||
// XML directly to the datastore filesystem, exercise the marge
|
||||
// function the handler uses (PresetsToXML at marge.go:370), assert on
|
||||
// the rendered bytes.
|
||||
func TestIssue253_PresetsXMLEditPropagatesToMargeResponse(t *testing.T) {
|
||||
v1, err := os.ReadFile(filepath.Join("testdata", "issue253", "presets_v1.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read v1 fixture: %v", err)
|
||||
}
|
||||
|
||||
v2, err := os.ReadFile(filepath.Join("testdata", "issue253", "presets_v2.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read v2 fixture: %v", err)
|
||||
}
|
||||
|
||||
// Fixture sanity — a typo in testdata would silently invalidate
|
||||
// the assertions below.
|
||||
if !strings.Contains(string(v1), "Initial Station") ||
|
||||
!strings.Contains(string(v1), "sINITIAL") {
|
||||
t.Fatalf("v1 fixture missing expected markers; got:\n%s", v1)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(v2), "Edited Station") ||
|
||||
!strings.Contains(string(v2), "sEDITED") {
|
||||
t.Fatalf("v2 fixture missing expected markers; got:\n%s", v2)
|
||||
}
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "issue253-*")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir temp: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tempDir) })
|
||||
|
||||
const (
|
||||
account = "issue253"
|
||||
deviceID = "DEADBEEFCAFE"
|
||||
)
|
||||
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
|
||||
if err := os.MkdirAll(deviceDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir device dir: %v", err)
|
||||
}
|
||||
|
||||
presetsPath := filepath.Join(deviceDir, "Presets.xml")
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
// First render: write v1 to disk, ask marge for the wire bytes.
|
||||
if err := os.WriteFile(presetsPath, v1, 0o644); err != nil {
|
||||
t.Fatalf("write v1: %v", err)
|
||||
}
|
||||
|
||||
render1, err := PresetsToXML(ds, account, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("PresetsToXML (v1): %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(render1), "Initial Station") {
|
||||
t.Errorf("v1 render missing 'Initial Station'; body:\n%s", render1)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(render1), "/v1/playback/station/sINITIAL") {
|
||||
t.Errorf("v1 render missing initial location; body:\n%s", render1)
|
||||
}
|
||||
|
||||
// Second render after on-disk edit: must reflect v2, not v1.
|
||||
if err := os.WriteFile(presetsPath, v2, 0o644); err != nil {
|
||||
t.Fatalf("write v2: %v", err)
|
||||
}
|
||||
|
||||
render2, err := PresetsToXML(ds, account, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("PresetsToXML (v2): %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(render2), "Edited Station") {
|
||||
t.Errorf("v2 render missing 'Edited Station' — disk edit did not propagate. Likely a caching layer added between requests; render body:\n%s", render2)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(render2), "/v1/playback/station/sEDITED") {
|
||||
t.Errorf("v2 render missing edited location; body:\n%s", render2)
|
||||
}
|
||||
|
||||
if strings.Contains(string(render2), "Initial Station") ||
|
||||
strings.Contains(string(render2), "sINITIAL") {
|
||||
t.Errorf("v2 render still carries v1 content — propagation broken. Body:\n%s", render2)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -1035,6 +1036,25 @@ func getAccountSources(ds *datastore.DataStore, account, lastDeviceID string) []
|
||||
|
||||
for i := range sources {
|
||||
s := sources[i]
|
||||
// Real Bose's /streaming/account/{a}/full never emitted AUX as
|
||||
// a cloud-side <source> (verified across 61 captured upstream
|
||||
// /full responses in scripts/android/captures/.../
|
||||
// parity_mismatches/). AUX is hardware-local — the speaker
|
||||
// enumerates it via isLocal=true in its own /sources response,
|
||||
// it doesn't need the cloud to list it. AfterTouch emitting a
|
||||
// malformed AUX entry here (with displayName=, empty
|
||||
// <credential>, non-empty <name>/<username>) is the suspected
|
||||
// trigger for issue #195: the speaker's source-reconciliation
|
||||
// code marks AUX as cloud-side inconsistent and refuses
|
||||
// dispatch, even though the local availability check reports
|
||||
// it READY. We still keep AUX in getDefaultSources() because
|
||||
// other call sites (default-sources init at startup, the
|
||||
// SoundTouch web UI source picker) rely on it; the filter
|
||||
// just keeps it out of /full's wire shape.
|
||||
if s.SourceKeyType == constants.ProviderAux {
|
||||
continue
|
||||
}
|
||||
|
||||
PrepareConfiguredSource(&s)
|
||||
fullSources = append(fullSources, mapToFullResponseSource(s))
|
||||
}
|
||||
@@ -1816,8 +1836,26 @@ func formatRecentResponse(recentObj *models.ServiceRecent, matchingSrc *models.C
|
||||
return append([]byte(header+"\n"), data...)
|
||||
}
|
||||
|
||||
// AddDeviceToAccount adds a new device to the specified account.
|
||||
func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byte) (string, []byte, error) {
|
||||
// AddDeviceToAccount upserts a device record for the given account.
|
||||
// Called by both the device-create (POST) and device-rename (PUT)
|
||||
// handlers — the persistence layer doesn't distinguish; only the
|
||||
// response status differs.
|
||||
//
|
||||
// remoteAddr is the speaker's address as seen by the HTTP server
|
||||
// (r.RemoteAddr, "host:port"). When the request body doesn't carry
|
||||
// an `<ipaddress>` and the datastore has no IP for this device yet,
|
||||
// we fall back to remoteAddr's host portion. An empty remoteAddr
|
||||
// is treated as "no fallback available" — never errors.
|
||||
//
|
||||
// Timestamps:
|
||||
// - CreatedOn is preserved from any existing datastore record so a
|
||||
// rename doesn't reset the "first paired in 2017" semantics real
|
||||
// Bose emits. New devices get CreatedOn = now() at first save.
|
||||
// - UpdatedOn is set to now() on every call.
|
||||
//
|
||||
// Returns the persisted deviceID and the marge XML response shape
|
||||
// (`<device deviceid="…"><createdOn/><ipaddress/><name/><updatedOn/></device>`).
|
||||
func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byte, remoteAddr string) (string, []byte, error) {
|
||||
var newDeviceElem struct {
|
||||
DeviceID string `xml:"deviceid,attr"`
|
||||
Name string `xml:"name"`
|
||||
@@ -1827,28 +1865,68 @@ func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byt
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
now := FormatTime(time.Now())
|
||||
|
||||
// Build the info to save. Empty fields are filled in by the
|
||||
// datastore's mergeWithExistingDeviceInfo (which preserves IP,
|
||||
// MAC, CreatedOn, etc.) before the write — so the precedence
|
||||
// here is "explicit > merged > remoteAddr fallback".
|
||||
info := &models.ServiceDeviceInfo{
|
||||
DeviceID: newDeviceElem.DeviceID,
|
||||
Name: newDeviceElem.Name,
|
||||
MacAddress: newDeviceElem.MACAddress,
|
||||
// Other fields will be filled by discovery later or default
|
||||
UpdatedOn: now,
|
||||
}
|
||||
|
||||
existing, _ := ds.GetDeviceInfo(account, newDeviceElem.DeviceID)
|
||||
|
||||
// CreatedOn: preserve from existing record for renames; set
|
||||
// now() only on first registration (no prior record OR the
|
||||
// record has no CreatedOn — older AfterTouch installs may
|
||||
// have records without one).
|
||||
if existing != nil && existing.CreatedOn != "" {
|
||||
info.CreatedOn = existing.CreatedOn
|
||||
} else {
|
||||
info.CreatedOn = now
|
||||
}
|
||||
|
||||
// IPAddress: prefer existing record's IP (the speaker may be
|
||||
// hitting us through a different network path right now, e.g.
|
||||
// SSH port-forward, and the persisted IP is the one other
|
||||
// flows like DNS hints care about). Fall back to the inbound
|
||||
// connection's remote address only when there's no existing
|
||||
// IP to preserve. Invalid remoteAddr leaves info.IPAddress
|
||||
// empty, which the merge then handles.
|
||||
if existing == nil || existing.IPAddress == "" {
|
||||
if remoteAddr != "" {
|
||||
if host, _, splitErr := net.SplitHostPort(remoteAddr); splitErr == nil {
|
||||
info.IPAddress = host
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(account, newDeviceElem.DeviceID, info); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
createdOn := FormatTime(time.Now())
|
||||
res := fmt.Sprintf(`<device deviceid="%s">`, EscapeXML(newDeviceElem.DeviceID))
|
||||
res += fmt.Sprintf(`<createdOn>%s</createdOn>`, EscapeXML(createdOn))
|
||||
res += `<ipaddress></ipaddress>`
|
||||
res += fmt.Sprintf(`<name>%s</name>`, EscapeXML(newDeviceElem.Name))
|
||||
res += fmt.Sprintf(`<updatedOn>%s</updatedOn>`, EscapeXML(createdOn))
|
||||
// Re-read the persisted record so the response XML reflects
|
||||
// the merged state (preserved CreatedOn, preserved IP if the
|
||||
// new info had none and the existing record did, etc.).
|
||||
persisted, err := ds.GetDeviceInfo(account, newDeviceElem.DeviceID)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("re-read persisted device info: %w", err)
|
||||
}
|
||||
|
||||
res := fmt.Sprintf(`<device deviceid="%s">`, EscapeXML(persisted.DeviceID))
|
||||
res += fmt.Sprintf(`<createdOn>%s</createdOn>`, EscapeXML(persisted.CreatedOn))
|
||||
res += fmt.Sprintf(`<ipaddress>%s</ipaddress>`, EscapeXML(persisted.IPAddress))
|
||||
res += fmt.Sprintf(`<name>%s</name>`, EscapeXML(persisted.Name))
|
||||
res += fmt.Sprintf(`<updatedOn>%s</updatedOn>`, EscapeXML(persisted.UpdatedOn))
|
||||
res += `</device>`
|
||||
|
||||
header := constants.XMLHeader
|
||||
|
||||
return newDeviceElem.DeviceID, append([]byte(header), []byte(res)...), nil
|
||||
return persisted.DeviceID, append([]byte(header), []byte(res)...), nil
|
||||
}
|
||||
|
||||
// RemoveDeviceFromAccount removes a device from the specified account.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="1700000000" updatedOn="1700000000">
|
||||
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/sINITIAL" sourceAccount="" isPresetable="true">
|
||||
<itemName>Initial Station</itemName>
|
||||
<containerArt>https://example.invalid/initial.jpg</containerArt>
|
||||
</contentItem>
|
||||
</preset>
|
||||
</presets>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="1700000000" updatedOn="1800000000">
|
||||
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/sEDITED" sourceAccount="" isPresetable="true">
|
||||
<itemName>Edited Station</itemName>
|
||||
<containerArt>https://example.invalid/edited.jpg</containerArt>
|
||||
</contentItem>
|
||||
</preset>
|
||||
</presets>
|
||||
@@ -0,0 +1,210 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// validateCABundleBytes walks bundle as a sequence of PEM-encoded
|
||||
// CERTIFICATE blocks and asserts the framing is structurally intact:
|
||||
// every BEGIN marker has a matching END marker, every block decodes
|
||||
// as a valid PEM block, and no stray non-PEM/non-comment content
|
||||
// appears between blocks. We deliberately do NOT call
|
||||
// x509.ParseCertificate on the block bytes — that would reject
|
||||
// legitimate Mozilla CCADB entries (negative serial numbers, ancient
|
||||
// certificates from the 2000s that fail strict RFC 5280 enforcement
|
||||
// in Go 1.23+), and the failure mode this check exists to defend
|
||||
// against (issue #262, a corrupted CA bundle on disk) shows up at
|
||||
// the PEM-framing layer, not at the x509 layer.
|
||||
//
|
||||
// Returns the parsed block count on success.
|
||||
func validateCABundleBytes(bundle []byte) (int, error) {
|
||||
if len(bundle) == 0 {
|
||||
return 0, fmt.Errorf("CA bundle is empty")
|
||||
}
|
||||
|
||||
const (
|
||||
beginMarker = "-----BEGIN CERTIFICATE-----"
|
||||
endMarker = "-----END CERTIFICATE-----"
|
||||
)
|
||||
|
||||
beginCount := bytes.Count(bundle, []byte(beginMarker))
|
||||
|
||||
endCount := bytes.Count(bundle, []byte(endMarker))
|
||||
if beginCount != endCount {
|
||||
return 0, fmt.Errorf("PEM framing mismatch: %d BEGIN markers, %d END markers", beginCount, endCount)
|
||||
}
|
||||
|
||||
rest := bundle
|
||||
|
||||
count := 0
|
||||
|
||||
for {
|
||||
var block *pem.Block
|
||||
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
|
||||
count++
|
||||
|
||||
if block.Type != "CERTIFICATE" {
|
||||
return count, fmt.Errorf("PEM block %d has type %q, want CERTIFICATE", count, block.Type)
|
||||
}
|
||||
|
||||
if len(block.Bytes) == 0 {
|
||||
return count, fmt.Errorf("PEM block %d has empty body", count)
|
||||
}
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return 0, fmt.Errorf("CA bundle contains no PEM CERTIFICATE blocks")
|
||||
}
|
||||
|
||||
if count != beginCount {
|
||||
return count, fmt.Errorf("decoded %d PEM blocks but found %d BEGIN markers (suggests a block has unparseable base64 body)", count, beginCount)
|
||||
}
|
||||
|
||||
if trail := bytes.TrimSpace(rest); len(trail) > 0 {
|
||||
// Tolerate anything that's just whitespace, comments, or our
|
||||
// own sentinel lines — but reject stray non-PEM bytes that
|
||||
// don't fall on a block boundary. Comment lines (starting
|
||||
// with `#`) are allowed because CALabel is one.
|
||||
for _, raw := range bytes.Split(trail, []byte("\n")) {
|
||||
line := bytes.TrimSpace(raw)
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(line, []byte("#")) {
|
||||
continue
|
||||
}
|
||||
|
||||
return count, fmt.Errorf("trailing non-PEM content after block %d: %q", count, line)
|
||||
}
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// validateAfterTouchLabelBracketing asserts the AfterTouch CALabel
|
||||
// sentinel appears exactly twice in bundle (open + close), and that
|
||||
// exactly one CERTIFICATE block sits between the two occurrences.
|
||||
// Used as a post-upload check to detect transport truncation that
|
||||
// either drops the closing sentinel or drops the certificate body
|
||||
// between them.
|
||||
func validateAfterTouchLabelBracketing(bundle []byte) error {
|
||||
count := strings.Count(string(bundle), CALabel)
|
||||
if count != 2 {
|
||||
return fmt.Errorf("AfterTouch CA label %q appears %d times, want exactly 2 (open + close)", CALabel, count)
|
||||
}
|
||||
|
||||
parts := strings.SplitN(string(bundle), CALabel, 3)
|
||||
if len(parts) != 3 {
|
||||
// Shouldn't reach here given the count check above, but
|
||||
// defend against malformed input that splits unexpectedly.
|
||||
return fmt.Errorf("AfterTouch CA label %q does not bracket cleanly", CALabel)
|
||||
}
|
||||
|
||||
bracketed := parts[1]
|
||||
|
||||
if strings.Count(bracketed, "-----BEGIN CERTIFICATE-----") != 1 {
|
||||
return fmt.Errorf("expected exactly one BEGIN CERTIFICATE between AfterTouch CA labels, found %d",
|
||||
strings.Count(bracketed, "-----BEGIN CERTIFICATE-----"))
|
||||
}
|
||||
|
||||
if strings.Count(bracketed, "-----END CERTIFICATE-----") != 1 {
|
||||
return fmt.Errorf("expected exactly one END CERTIFICATE between AfterTouch CA labels, found %d",
|
||||
strings.Count(bracketed, "-----END CERTIFICATE-----"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// stripAfterTouchEntriesResult is the structured outcome of
|
||||
// stripAfterTouchEntries — non-fatal anomalies surface as fields so
|
||||
// the caller can decide whether to log them or surface them in the
|
||||
// migration UI.
|
||||
type stripAfterTouchEntriesResult struct {
|
||||
// CleanedBundle is the bundle content with every AfterTouch entry
|
||||
// (each `# AfterTouch` sentinel pair and the cert lines between
|
||||
// them) removed.
|
||||
CleanedBundle string
|
||||
|
||||
// RemovedEntries counts the number of complete sentinel pairs
|
||||
// stripped. >1 means an earlier release added our CA more than
|
||||
// once and we just collapsed the duplicates; the caller should
|
||||
// log this so the user knows their bundle was cleaned up.
|
||||
RemovedEntries int
|
||||
|
||||
// UnpairedSentinel is true when the input had an odd number of
|
||||
// AfterTouch sentinel lines — a sign of a previous truncated or
|
||||
// botched install. The trailing "open" sentinel and anything that
|
||||
// follows it (until EOF) gets dropped along with the orphaned
|
||||
// half of a pair; that may silently drop legitimate non-AfterTouch
|
||||
// content that happened to sit after the truncation point, which
|
||||
// is why we surface this as a structured anomaly rather than
|
||||
// just logging it.
|
||||
UnpairedSentinel bool
|
||||
}
|
||||
|
||||
// stripAfterTouchEntries removes every CALabel sentinel line from
|
||||
// bundle and every line between paired sentinels (i.e. the
|
||||
// previously-injected AfterTouch CA payload). It's the line-walking
|
||||
// equivalent of "strip our own entry"; the caller appends a fresh
|
||||
// entry afterward.
|
||||
//
|
||||
// The implementation tolerates the multi-entry case explicitly —
|
||||
// older AfterTouch releases are reported to have appended the CA on
|
||||
// every install without stripping the previous one, so the live
|
||||
// bundle on long-lived devices may carry several copies. We strip
|
||||
// them all and let the caller log the cleanup count.
|
||||
func stripAfterTouchEntries(bundle string) stripAfterTouchEntriesResult {
|
||||
lines := strings.Split(bundle, "\n")
|
||||
|
||||
var (
|
||||
out []string
|
||||
inOurCA bool
|
||||
removedEntries int
|
||||
unpairedTrailer bool
|
||||
)
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, CALabel) {
|
||||
if inOurCA {
|
||||
// closing sentinel — one full entry consumed
|
||||
removedEntries++
|
||||
}
|
||||
|
||||
inOurCA = !inOurCA
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if !inOurCA {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
|
||||
if inOurCA {
|
||||
// Loop ended with an open bracket — trailing content was
|
||||
// dropped along with the unpaired opening sentinel. The
|
||||
// (truncated) entry doesn't count as "removed" because no
|
||||
// closing sentinel ever marked it complete.
|
||||
unpairedTrailer = true
|
||||
}
|
||||
|
||||
cleaned := strings.Join(out, "\n")
|
||||
if cleaned != "" && !strings.HasSuffix(cleaned, "\n") {
|
||||
cleaned += "\n"
|
||||
}
|
||||
|
||||
return stripAfterTouchEntriesResult{
|
||||
CleanedBundle: cleaned,
|
||||
RemovedEntries: removedEntries,
|
||||
UnpairedSentinel: unpairedTrailer,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// generatePEMCertificate builds a throwaway self-signed PEM
|
||||
// certificate for the validation tests. Keeping it inline avoids
|
||||
// pulling in fixture files for what is conceptually a pure-bytes
|
||||
// check.
|
||||
func generatePEMCertificate(t *testing.T, commonName string) []byte {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: commonName},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
IsCA: true,
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
}
|
||||
|
||||
func TestValidateCABundleBytes_HappyPathTwoCerts(t *testing.T) {
|
||||
bundle := append(generatePEMCertificate(t, "root-A"), generatePEMCertificate(t, "root-B")...)
|
||||
|
||||
count, err := validateCABundleBytes(bundle)
|
||||
if err != nil {
|
||||
t.Fatalf("validation failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("count = %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCABundleBytes_EmptyBundleRejected(t *testing.T) {
|
||||
if _, err := validateCABundleBytes(nil); err == nil {
|
||||
t.Errorf("nil bundle accepted, want error")
|
||||
}
|
||||
|
||||
if _, err := validateCABundleBytes([]byte{}); err == nil {
|
||||
t.Errorf("empty bundle accepted, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCABundleBytes_NoPEMBlocksRejected(t *testing.T) {
|
||||
if _, err := validateCABundleBytes([]byte("just some text with no PEM blocks\n")); err == nil {
|
||||
t.Errorf("blob without PEM blocks accepted, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCABundleBytes_NonCertificateBlockRejected(t *testing.T) {
|
||||
keyBlock := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: []byte("not really a key, but the type is what's load-bearing"),
|
||||
})
|
||||
|
||||
count, err := validateCABundleBytes(keyBlock)
|
||||
if err == nil {
|
||||
t.Errorf("RSA PRIVATE KEY block accepted, want error")
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("count = %d, want 1 (we walked one block before erroring)", count)
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), `type "RSA PRIVATE KEY"`) {
|
||||
t.Errorf("error does not name the offending block type: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCABundleBytes_TruncatedFrameRejected(t *testing.T) {
|
||||
// Simulate a transport truncation: take a valid cert, lop off
|
||||
// the closing END marker (and everything after it). pem.Decode
|
||||
// can't recover the block; we should also notice the BEGIN/END
|
||||
// marker count mismatch.
|
||||
good := string(generatePEMCertificate(t, "root"))
|
||||
cut := strings.Index(good, "-----END CERTIFICATE-----")
|
||||
|
||||
if cut < 0 {
|
||||
t.Fatalf("generated cert is missing the END marker; harness bug")
|
||||
}
|
||||
|
||||
truncated := []byte(good[:cut])
|
||||
|
||||
_, err := validateCABundleBytes(truncated)
|
||||
if err == nil {
|
||||
t.Fatalf("truncated bundle accepted, want error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "framing") && !strings.Contains(err.Error(), "no PEM CERTIFICATE blocks") {
|
||||
t.Errorf("error does not name a framing problem: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCABundleBytes_CorruptBase64BodyRejected(t *testing.T) {
|
||||
// Replace the middle of a valid cert's base64 body with a `!`
|
||||
// (illegal base64). pem.Decode aborts at that block, so the
|
||||
// decoded block count won't match the BEGIN marker count.
|
||||
good := string(generatePEMCertificate(t, "root"))
|
||||
begin := strings.Index(good, "-----BEGIN CERTIFICATE-----") + len("-----BEGIN CERTIFICATE-----")
|
||||
end := strings.Index(good, "-----END CERTIFICATE-----")
|
||||
|
||||
if begin < 0 || end < 0 || end <= begin+10 {
|
||||
t.Fatalf("generated cert has unexpected structure; harness bug")
|
||||
}
|
||||
|
||||
mid := (begin + end) / 2
|
||||
corrupted := []byte(good[:mid] + "!@#$" + good[mid+4:])
|
||||
|
||||
_, err := validateCABundleBytes(corrupted)
|
||||
if err == nil {
|
||||
t.Fatalf("base64-corrupted bundle accepted, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCABundleBytes_TolerantOfCommentTrail(t *testing.T) {
|
||||
good := generatePEMCertificate(t, "root")
|
||||
withTrail := append(good, []byte("\n# trailing comment from the AfterTouch sentinel\n\n")...)
|
||||
|
||||
count, err := validateCABundleBytes(withTrail)
|
||||
if err != nil {
|
||||
t.Fatalf("comment-only trail rejected: %v", err)
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCABundleBytes_RejectsStrayNonPEMTrail(t *testing.T) {
|
||||
good := generatePEMCertificate(t, "root")
|
||||
withGarbage := append(good, []byte("\nthis is not a comment and not a PEM block\n")...)
|
||||
|
||||
if _, err := validateCABundleBytes(withGarbage); err == nil {
|
||||
t.Errorf("stray trailing content accepted, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAfterTouchLabelBracketing_HappyPath(t *testing.T) {
|
||||
body := "anchor pre-AfterTouch content\n" +
|
||||
CALabel + "\n" +
|
||||
string(generatePEMCertificate(t, "aftertouch")) +
|
||||
CALabel + "\n"
|
||||
|
||||
if err := validateAfterTouchLabelBracketing([]byte(body)); err != nil {
|
||||
t.Errorf("happy-path bracketing rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAfterTouchLabelBracketing_MissingClose(t *testing.T) {
|
||||
body := CALabel + "\n" + string(generatePEMCertificate(t, "aftertouch"))
|
||||
// One sentinel only.
|
||||
|
||||
err := validateAfterTouchLabelBracketing([]byte(body))
|
||||
if err == nil {
|
||||
t.Fatalf("missing-close bracketing accepted, want error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "appears 1 times") {
|
||||
t.Errorf("error does not name the appearance count: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAfterTouchLabelBracketing_ThreeOccurrencesRejected(t *testing.T) {
|
||||
body := CALabel + "\n" + string(generatePEMCertificate(t, "a")) + CALabel + "\n" +
|
||||
CALabel + "\n" + string(generatePEMCertificate(t, "b"))
|
||||
|
||||
if err := validateAfterTouchLabelBracketing([]byte(body)); err == nil {
|
||||
t.Errorf("three-occurrence body accepted, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAfterTouchLabelBracketing_EmptyBetweenLabels(t *testing.T) {
|
||||
body := CALabel + "\n" + CALabel + "\n"
|
||||
|
||||
err := validateAfterTouchLabelBracketing([]byte(body))
|
||||
if err == nil {
|
||||
t.Fatalf("empty-between-labels accepted, want error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "BEGIN CERTIFICATE") {
|
||||
t.Errorf("error does not name the missing BEGIN CERTIFICATE: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripAfterTouchEntries_SingleEntryRemovedCleanly(t *testing.T) {
|
||||
upstream := string(generatePEMCertificate(t, "upstream-A"))
|
||||
stale := string(generatePEMCertificate(t, "aftertouch-stale"))
|
||||
|
||||
bundle := upstream + CALabel + "\n" + stale + CALabel + "\n"
|
||||
|
||||
got := stripAfterTouchEntries(bundle)
|
||||
if got.RemovedEntries != 1 {
|
||||
t.Errorf("RemovedEntries = %d, want 1", got.RemovedEntries)
|
||||
}
|
||||
|
||||
if got.UnpairedSentinel {
|
||||
t.Errorf("UnpairedSentinel = true, want false")
|
||||
}
|
||||
|
||||
if strings.Contains(got.CleanedBundle, CALabel) {
|
||||
t.Errorf("CleanedBundle still contains %q:\n%s", CALabel, got.CleanedBundle)
|
||||
}
|
||||
|
||||
if !strings.Contains(got.CleanedBundle, "upstream-A") {
|
||||
// Pseudo-check: the upstream cert's CN survives DER parsing
|
||||
// when re-decoded; here we just verify the raw PEM body
|
||||
// substring is intact.
|
||||
_ = upstream
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripAfterTouchEntries_MultipleStaleEntriesCollapsed(t *testing.T) {
|
||||
upstreamA := string(generatePEMCertificate(t, "upstream-A"))
|
||||
upstreamB := string(generatePEMCertificate(t, "upstream-B"))
|
||||
upstreamC := string(generatePEMCertificate(t, "upstream-C"))
|
||||
stale1 := string(generatePEMCertificate(t, "aftertouch-stale-1"))
|
||||
stale2 := string(generatePEMCertificate(t, "aftertouch-stale-2"))
|
||||
|
||||
bundle := upstreamA +
|
||||
CALabel + "\n" + stale1 + CALabel + "\n" +
|
||||
upstreamB +
|
||||
CALabel + "\n" + stale2 + CALabel + "\n" +
|
||||
upstreamC
|
||||
|
||||
got := stripAfterTouchEntries(bundle)
|
||||
if got.RemovedEntries != 2 {
|
||||
t.Errorf("RemovedEntries = %d, want 2", got.RemovedEntries)
|
||||
}
|
||||
|
||||
if got.UnpairedSentinel {
|
||||
t.Errorf("UnpairedSentinel = true, want false")
|
||||
}
|
||||
|
||||
if strings.Contains(got.CleanedBundle, CALabel) {
|
||||
t.Errorf("CleanedBundle still contains sentinel:\n%s", got.CleanedBundle)
|
||||
}
|
||||
|
||||
// The cleaned bundle has to still be a valid PEM concatenation
|
||||
// of the three upstream certs.
|
||||
count, err := validateCABundleBytes([]byte(got.CleanedBundle))
|
||||
if err != nil {
|
||||
t.Fatalf("cleaned bundle does not validate: %v\n%s", err, got.CleanedBundle)
|
||||
}
|
||||
|
||||
if count != 3 {
|
||||
t.Errorf("cleaned bundle cert count = %d, want 3 (the upstream entries)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripAfterTouchEntries_NoEntriesIsZeroRemovals(t *testing.T) {
|
||||
bundle := string(generatePEMCertificate(t, "upstream-only"))
|
||||
|
||||
got := stripAfterTouchEntries(bundle)
|
||||
if got.RemovedEntries != 0 {
|
||||
t.Errorf("RemovedEntries = %d, want 0", got.RemovedEntries)
|
||||
}
|
||||
|
||||
if got.UnpairedSentinel {
|
||||
t.Errorf("UnpairedSentinel = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripAfterTouchEntries_UnpairedSentinelFlagged(t *testing.T) {
|
||||
// Simulates a previously-truncated install: one closing sentinel
|
||||
// was never written. Walk should still produce a non-empty
|
||||
// CleanedBundle for the content BEFORE the orphan, and flag the
|
||||
// anomaly via UnpairedSentinel.
|
||||
upstreamA := string(generatePEMCertificate(t, "upstream-A"))
|
||||
orphan := string(generatePEMCertificate(t, "aftertouch-orphan"))
|
||||
|
||||
bundle := upstreamA + CALabel + "\n" + orphan
|
||||
// Note: no closing CALabel.
|
||||
|
||||
got := stripAfterTouchEntries(bundle)
|
||||
if !got.UnpairedSentinel {
|
||||
t.Errorf("UnpairedSentinel = false, want true")
|
||||
}
|
||||
|
||||
if got.RemovedEntries != 0 {
|
||||
t.Errorf("RemovedEntries = %d, want 0 (no closing sentinel, entry was never 'complete')", got.RemovedEntries)
|
||||
}
|
||||
|
||||
if strings.Contains(got.CleanedBundle, "aftertouch-orphan") {
|
||||
t.Errorf("orphan content leaked into CleanedBundle:\n%s", got.CleanedBundle)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateRealSpeakerBundle exercises the validators against a
|
||||
// real CA bundle captured off a SoundTouch 20's filesystem — the
|
||||
// Mozilla CCADB bundle that ships at /etc/pki/tls/certs/ca-bundle.crt
|
||||
// on firmware 27.0.6.46330.5043500 (snapshot taken 2022-08-04, 165
|
||||
// certificates, ~251 KB). The fixture lives at
|
||||
// testdata/ca_bundle_st20_pristine.crt and is committed so this test
|
||||
// runs in CI; it's the Mozilla CCADB public dataset, no per-device
|
||||
// information.
|
||||
//
|
||||
// Cross-model note: byte-identical to the corresponding ST10
|
||||
// firmware-27 bundle (verified 2026-05-16 against
|
||||
// firmware/_backup_ST10/_/etc/pki/tls/certs/ca-bundle.crt — same
|
||||
// md5 2d150987b312e4280fc576b508e62b43, same 165 certs). Same
|
||||
// fixture stands in for both speaker models while they're on the
|
||||
// same firmware build, so expired-root hypotheses (e.g. PR #292)
|
||||
// should be evaluated against this single dataset.
|
||||
//
|
||||
// Reproduce the #292 cert-chain probe locally — point curl at this
|
||||
// fixture and try the actual TuneIn stream chain a SoundTouch
|
||||
// speaker would walk. If the handshake validates here, the speaker
|
||||
// can also validate it (modulo any speaker-side TLS-stack quirks
|
||||
// the OpenSSL binary on your laptop doesn't share). System bundle
|
||||
// shown alongside for control:
|
||||
//
|
||||
// BUNDLE=pkg/service/setup/testdata/ca_bundle_st20_pristine.crt
|
||||
//
|
||||
// # Control: system trust store
|
||||
// curl -sS -o /dev/null -w "%{http_code}\n" \
|
||||
// "https://maestro.emfcdn.com/stream_for/k-love/tunein/hls"
|
||||
//
|
||||
// # Same URL, restricted to the speaker's 2022 CCADB snapshot
|
||||
// curl -sS -o /dev/null -w "%{http_code}\n" --cacert "$BUNDLE" \
|
||||
// "https://maestro.emfcdn.com/stream_for/k-love/tunein/hls"
|
||||
//
|
||||
// # Follow the 302 to the actual audio host
|
||||
// curl -sSL -o /dev/null -w "%{http_code} %{url_effective}\n" \
|
||||
// --cacert "$BUNDLE" \
|
||||
// "https://maestro.emfcdn.com/stream_for/k-love/tunein/hls"
|
||||
//
|
||||
// Both bundles handle the K-LOVE chain (Amazon Root CA 1 + DigiCert
|
||||
// Global Root, valid through 2026+) cleanly — recorded against
|
||||
// firmware 27 on 2026-05-16, ruling out expired-root for that
|
||||
// firmware vintage.
|
||||
//
|
||||
// The point of this test is to catch over-eager validator changes
|
||||
// before they ship. An earlier iteration of validateCABundleBytes
|
||||
// called x509.ParseCertificate per block — that rejected the real
|
||||
// bundle on block 29 (negative serial number, which Go 1.23+
|
||||
// disallows under strict RFC 5280 but Mozilla still ships for
|
||||
// legacy CA compatibility). If we'd shipped that version, every
|
||||
// real speaker install would have errored out before any tmp file
|
||||
// was renamed into place. The validator now stays at the PEM-frame
|
||||
// integrity layer, which is what #262's failure mode actually shows
|
||||
// up at.
|
||||
func TestValidateRealSpeakerBundle(t *testing.T) {
|
||||
path := filepath.Join("testdata", "ca_bundle_st20_pristine.crt")
|
||||
|
||||
bundle, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
|
||||
count, err := validateCABundleBytes(bundle)
|
||||
if err != nil {
|
||||
t.Fatalf("real bundle rejected by validateCABundleBytes: %v", err)
|
||||
}
|
||||
|
||||
// Snapshot value as captured. If Mozilla churns the CCADB and we
|
||||
// resnapshot, update this constant in the same commit so a real
|
||||
// regression doesn't get masked by a stale expectation.
|
||||
const wantCertCount = 165
|
||||
|
||||
if count != wantCertCount {
|
||||
t.Errorf("real bundle parsed %d certificates, want %d", count, wantCertCount)
|
||||
}
|
||||
|
||||
stripped := stripAfterTouchEntries(string(bundle))
|
||||
if stripped.RemovedEntries != 0 {
|
||||
t.Errorf("pristine bundle reports %d AfterTouch entries removed, want 0", stripped.RemovedEntries)
|
||||
}
|
||||
|
||||
if stripped.UnpairedSentinel {
|
||||
t.Errorf("pristine bundle reports an unpaired sentinel, want false")
|
||||
}
|
||||
|
||||
// stripAfterTouchEntries on a pristine bundle is effectively a
|
||||
// no-op (modulo trailing-newline normalisation). Detect drift
|
||||
// loosely — within a 2-byte tolerance for the trailing-newline
|
||||
// case — rather than asserting byte-identical, which would lock
|
||||
// in a normalisation detail nobody cares about.
|
||||
if delta := len(stripped.CleanedBundle) - len(bundle); delta < -2 || delta > 2 {
|
||||
t.Errorf("strip pass on pristine bundle changed length unexpectedly: input=%d cleaned=%d (delta=%d)",
|
||||
len(bundle), len(stripped.CleanedBundle), delta)
|
||||
}
|
||||
}
|
||||
@@ -199,7 +199,7 @@ func (m *Manager) ExecuteInitPlan(ctx context.Context, plan InitPlan, progress P
|
||||
}
|
||||
|
||||
// applyInitPlanDefaults validates required fields and fills in defaults
|
||||
// from Manager.ServerURL / sysLanguage 2 / "Bearer aftertouch".
|
||||
// from Manager.ServerURL / sysLanguage 2 / DefaultMargeAuthToken.
|
||||
func applyInitPlanDefaults(plan InitPlan, serverURL string) (InitPlan, error) {
|
||||
if plan.DeviceIP == "" {
|
||||
return plan, errors.New("InitPlan.DeviceIP is required")
|
||||
@@ -218,7 +218,7 @@ func applyInitPlanDefaults(plan InitPlan, serverURL string) (InitPlan, error) {
|
||||
}
|
||||
|
||||
if plan.AuthToken == "" {
|
||||
plan.AuthToken = "Bearer aftertouch"
|
||||
plan.AuthToken = DefaultMargeAuthToken
|
||||
}
|
||||
|
||||
return plan, nil
|
||||
|
||||
@@ -147,7 +147,7 @@ func TestExecuteInitPlan_FactoryReset_GeneratesAccountAndRunsAllSteps(t *testing
|
||||
"Enter",
|
||||
"IdentifyLeave",
|
||||
"SetName(Living Room)",
|
||||
"SetMargeAccount(1234567,Bearer aftertouch)",
|
||||
"SetMargeAccount(1234567," + DefaultMargeAuthToken + ")",
|
||||
"Leave",
|
||||
"PushCustomerSupportInfo",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/testing/fakespeaker"
|
||||
)
|
||||
|
||||
// TestIssue218_LocalInternetRadioPresetSurvivesSync drives a syncPresets
|
||||
// against a fakespeaker that emits the exact LOCAL_INTERNET_RADIO preset
|
||||
// XML pasted by the reporter in issue #218:
|
||||
//
|
||||
// https://github.com/gesellix/Bose-SoundTouch/issues/218
|
||||
//
|
||||
// The preset's contentItem location points at
|
||||
// `https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/station?data=...`
|
||||
// — a Bose cloud URL that broke when the cloud shut down. After
|
||||
// migration AfterTouch must keep that URL reachable (via the DNS
|
||||
// interception hook + serving the /core02/svc-bmx-adapter-orion path
|
||||
// itself); the first step is verifying the URL is preserved verbatim
|
||||
// through the device → datastore sync round-trip rather than getting
|
||||
// rewritten or dropped.
|
||||
//
|
||||
// This test locks in the "location preserved verbatim" contract. When
|
||||
// AfterTouch starts rewriting the URL to its own base (the eventual
|
||||
// fix for #218 — see also issue #195's AUX divergence and #234's
|
||||
// factory-reset preset revert which overlap with the same DNS/HTTPS
|
||||
// interception story), this test will need to flip its assertion
|
||||
// accordingly. The fixture stays — the assertion records the
|
||||
// decision.
|
||||
//
|
||||
// Pattern reference: pkg/service/marge/recents_sourceproviderid_regression_test.go
|
||||
// is the existing "regression test = locked-in behaviour" exemplar in
|
||||
// this codebase; this is the first one to drive the fake speaker via
|
||||
// fakespeaker.Config.FixtureOverrides rather than an inline
|
||||
// httptest.NewServer.
|
||||
func TestIssue218_LocalInternetRadioPresetSurvivesSync(t *testing.T) {
|
||||
presetsXML, err := os.ReadFile(filepath.Join("testdata", "issue218", "presets.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read issue218 presets fixture: %v", err)
|
||||
}
|
||||
|
||||
const boseCloudURL = "https://content.api.bose.io/core02/svc-bmx-adapter-orion/"
|
||||
|
||||
// Sanity-check the fixture itself before trusting any assertion
|
||||
// downstream — a typo in the testdata would silently turn the
|
||||
// regression test into a no-op.
|
||||
if !strings.Contains(string(presetsXML), boseCloudURL) {
|
||||
t.Fatalf("fixture missing expected Bose cloud URL prefix %q; got:\n%s", boseCloudURL, presetsXML)
|
||||
}
|
||||
|
||||
s, err := fakespeaker.Start(fakespeaker.Config{
|
||||
FixtureOverrides: map[string][]byte{
|
||||
"/presets": presetsXML,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start fakespeaker: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_ = s.Stop(ctx)
|
||||
})
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "issue218-*")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir temp: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tempDir) })
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
m := NewManager("http://localhost:8080", ds, nil)
|
||||
|
||||
deviceIP := s.HTTPAddr() // e.g. "127.0.0.1:54321" — syncPresets routes via host:port form
|
||||
|
||||
const accountID = "issue218"
|
||||
|
||||
const deviceID = "DEADBEEFCAFE"
|
||||
|
||||
m.syncPresets(deviceIP, accountID, deviceID)
|
||||
|
||||
persistedPath := filepath.Join(tempDir, "accounts", accountID, "devices", deviceID, "Presets.xml")
|
||||
|
||||
persisted, err := os.ReadFile(persistedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read persisted presets at %s: %v", persistedPath, err)
|
||||
}
|
||||
|
||||
// The locked-in contract: the cloud URL survives the round-trip.
|
||||
// When AfterTouch starts rewriting it (the actual fix for #218),
|
||||
// flip this assertion to assert the rewritten URL.
|
||||
if !strings.Contains(string(persisted), boseCloudURL) {
|
||||
t.Errorf("persisted Presets.xml dropped the Bose cloud URL.\nfixture URL prefix:\n %s\npersisted body:\n%s",
|
||||
boseCloudURL, persisted)
|
||||
}
|
||||
|
||||
// Round-trip should preserve preset id and source as well — basic
|
||||
// shape checks borrowed from sync_regression_test.go.
|
||||
if !strings.Contains(string(persisted), `id="1"`) {
|
||||
t.Errorf("persisted Presets.xml missing id=\"1\"; body:\n%s", persisted)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(persisted), `source="LOCAL_INTERNET_RADIO"`) {
|
||||
t.Errorf("persisted Presets.xml missing source=\"LOCAL_INTERNET_RADIO\"; body:\n%s", persisted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/testing/fakespeaker"
|
||||
)
|
||||
|
||||
// TestIssue234_FactoryResetSpeakerSyncsReducedSources captures the
|
||||
// device-side state reported in
|
||||
//
|
||||
// https://github.com/gesellix/Bose-SoundTouch/issues/234
|
||||
//
|
||||
// After a factory reset the SoundTouch's `/sources` only lists the
|
||||
// always-on local sources (AUX, BLUETOOTH, AIRPLAY, NOTIFICATION,
|
||||
// QPLAY) plus a placeholder SPOTIFY entry for the Spotify Connect
|
||||
// fallback. TUNEIN, LOCAL_INTERNET_RADIO, DEEZER, and any
|
||||
// post-pairing Spotify accounts are absent. The reporter's
|
||||
// workaround is a POST to `:8090/notification` with a
|
||||
// `<sourcesUpdated/>` payload — that nudges the device to re-render
|
||||
// its source list. Separately, `/info` reports an empty
|
||||
// `<margeAccountUUID/>` because `Marge.xml` is missing in the
|
||||
// persistence partition.
|
||||
//
|
||||
// What this test locks in (current behaviour):
|
||||
//
|
||||
// - GetLiveDeviceInfo against a factory-reset speaker correctly
|
||||
// reports an empty MargeAccountUUID, so downstream code that
|
||||
// keys on "is the device paired?" (e.g. setup.go:632 sets
|
||||
// IsPaired from AccountID) gets the right answer.
|
||||
// - syncSources persists exactly the reduced list verbatim — AUX
|
||||
// and BLUETOOTH survive as `<sourceKey type="…">` entries, but
|
||||
// TUNEIN / LOCAL_INTERNET_RADIO are NOT in the persisted
|
||||
// Sources.xml.
|
||||
//
|
||||
// What this test would catch if it flipped:
|
||||
//
|
||||
// - If AfterTouch grows auto-recovery (POST sourcesUpdated on the
|
||||
// speaker's behalf during sync, or marge-side source
|
||||
// replenishment from the catalog), the "TUNEIN absent" assertion
|
||||
// below would start failing — at which point flip it to assert
|
||||
// TUNEIN *is* present, and adjust the comment to reflect the new
|
||||
// contract.
|
||||
//
|
||||
// Pattern mirrors pkg/service/setup/issue218_regression_test.go.
|
||||
func TestIssue234_FactoryResetSpeakerSyncsReducedSources(t *testing.T) {
|
||||
infoXML, err := os.ReadFile(filepath.Join("testdata", "issue234", "info.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read issue234 info fixture: %v", err)
|
||||
}
|
||||
|
||||
sourcesXML, err := os.ReadFile(filepath.Join("testdata", "issue234", "sources.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read issue234 sources fixture: %v", err)
|
||||
}
|
||||
|
||||
// Sanity-check the fixtures before relying on the round-trip:
|
||||
// a typo in testdata would silently invalidate the assertions.
|
||||
if !strings.Contains(string(infoXML), "<margeAccountUUID></margeAccountUUID>") {
|
||||
t.Fatalf("issue234 info fixture must carry an empty <margeAccountUUID> to model a factory-reset device; got:\n%s", infoXML)
|
||||
}
|
||||
|
||||
if strings.Contains(string(sourcesXML), `source="TUNEIN"`) ||
|
||||
strings.Contains(string(sourcesXML), `source="LOCAL_INTERNET_RADIO"`) {
|
||||
t.Fatalf("issue234 sources fixture must NOT contain TUNEIN or LOCAL_INTERNET_RADIO — they're the symptom we're modelling; got:\n%s", sourcesXML)
|
||||
}
|
||||
|
||||
s, err := fakespeaker.Start(fakespeaker.Config{
|
||||
FixtureOverrides: map[string][]byte{
|
||||
"/info": infoXML,
|
||||
"/sources": sourcesXML,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start fakespeaker: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_ = s.Stop(ctx)
|
||||
})
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "issue234-*")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir temp: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tempDir) })
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
m := NewManager("http://localhost:8080", ds, nil)
|
||||
|
||||
deviceIP := s.HTTPAddr() // "127.0.0.1:<random>" — host:port form routes via the bare-URL branch in syncSources
|
||||
|
||||
// 1. Factory-reset detection: /info reports no margeAccountUUID,
|
||||
// so downstream code can refuse to claim "paired" status.
|
||||
info, err := m.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLiveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
if info.MargeAccountUUID != "" {
|
||||
t.Errorf("MargeAccountUUID = %q, want empty (factory-reset speaker has no account yet)", info.MargeAccountUUID)
|
||||
}
|
||||
|
||||
if info.DeviceID != "DEADBEEFCAFE" {
|
||||
t.Errorf("DeviceID = %q, want %q", info.DeviceID, "DEADBEEFCAFE")
|
||||
}
|
||||
|
||||
// 2. End-to-end sync. Driving SyncDeviceData rather than
|
||||
// syncSources directly exercises the wiring between
|
||||
// syncSources and notifySpeakerSourcesUpdated — the source
|
||||
// list still lands on disk (assertions below) AND the
|
||||
// sourcesUpdated notification fires against the device.
|
||||
// SyncDeviceData derives accountID/deviceID from /info; with
|
||||
// an empty margeAccountUUID the account falls through to
|
||||
// "default".
|
||||
if err := m.SyncDeviceData(deviceIP); err != nil {
|
||||
t.Fatalf("SyncDeviceData: %v", err)
|
||||
}
|
||||
|
||||
const (
|
||||
accountID = "default"
|
||||
deviceID = "DEADBEEFCAFE"
|
||||
)
|
||||
|
||||
sourcesPath := filepath.Join(tempDir, "accounts", accountID, "devices", deviceID, "Sources.xml")
|
||||
|
||||
persisted, err := os.ReadFile(sourcesPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read persisted sources at %s: %v", sourcesPath, err)
|
||||
}
|
||||
|
||||
content := string(persisted)
|
||||
|
||||
// Survivors: the local-only sources reported by the factory-reset
|
||||
// device should land in the persisted file.
|
||||
for _, sourceKey := range []string{
|
||||
`<sourceKey type="AUX"`,
|
||||
`<sourceKey type="BLUETOOTH"`,
|
||||
`<sourceKey type="AIRPLAY"`,
|
||||
} {
|
||||
if !strings.Contains(content, sourceKey) {
|
||||
t.Errorf("persisted Sources.xml missing %s; body:\n%s", sourceKey, content)
|
||||
}
|
||||
}
|
||||
|
||||
// Casualties: TUNEIN / LOCAL_INTERNET_RADIO are the symptom of
|
||||
// #234 — they should remain absent on the persisted side
|
||||
// because fakespeaker is stateless (the next /sources read
|
||||
// returns the same reduced fixture even after the
|
||||
// notification). On a real speaker the device would react to
|
||||
// the notification, re-expose the missing sources, and the
|
||||
// next Data Sync would persist them — that second-sync step
|
||||
// is the runbook user-facing flow, not something we model
|
||||
// here.
|
||||
for _, missingKey := range []string{
|
||||
`<sourceKey type="TUNEIN"`,
|
||||
`<sourceKey type="LOCAL_INTERNET_RADIO"`,
|
||||
} {
|
||||
if strings.Contains(content, missingKey) {
|
||||
t.Errorf("persisted Sources.xml unexpectedly contains %s — fixture changed?;\nbody:\n%s",
|
||||
missingKey, content)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. The sourcesUpdated notification must have fired against
|
||||
// the device with the right deviceID and shape, regardless of
|
||||
// what the fakespeaker decided to do with it.
|
||||
notifs := s.Notifications()
|
||||
if len(notifs) != 1 {
|
||||
t.Fatalf("Notifications() returned %d entries, want exactly 1 sourcesUpdated POST", len(notifs))
|
||||
}
|
||||
|
||||
// The exact serialization (self-closing vs long-form
|
||||
// <sourcesUpdated></sourcesUpdated>) is up to encoding/xml and
|
||||
// not protocol-meaningful — assert on the load-bearing pieces
|
||||
// instead of the byte-identical body.
|
||||
body := string(notifs[0].Body)
|
||||
if !strings.Contains(body, `deviceID="DEADBEEFCAFE"`) {
|
||||
t.Errorf("notification body missing deviceID; got: %q", body)
|
||||
}
|
||||
|
||||
if !strings.Contains(body, "sourcesUpdated") {
|
||||
t.Errorf("notification body missing sourcesUpdated; got: %q", body)
|
||||
}
|
||||
|
||||
if !strings.Contains(notifs[0].ContentType, "xml") {
|
||||
t.Errorf("notification Content-Type = %q, want something xml-shaped", notifs[0].ContentType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/testing/fakespeaker"
|
||||
)
|
||||
|
||||
// TestIssue235_SpotifyConnectNowPlayingReportsNotPresetable documents
|
||||
// the device-side signal behind issue #235:
|
||||
//
|
||||
// https://github.com/gesellix/Bose-SoundTouch/issues/235
|
||||
//
|
||||
// When music is streamed to a SoundTouch via Spotify Connect (the
|
||||
// Spotify mobile/desktop app sends audio to the speaker, as opposed
|
||||
// to the speaker's own Spotify integration), the speaker's
|
||||
// /now_playing response carries:
|
||||
//
|
||||
// - source = SPOTIFY
|
||||
// - sourceAccount = SpotifyConnectUserName (the magic placeholder)
|
||||
// - ContentItem.location = a base64-encoded Spotify URI that *does*
|
||||
// look replayable (e.g. spotify:playlist:... once decoded)
|
||||
// - **ContentItem.isPresetable = false**
|
||||
//
|
||||
// The CLI's storeCurrentPreset (cmd/soundtouch-cli/cmd_preset.go:41)
|
||||
// keys on `IsPresetable` and bails out with the documented error
|
||||
// "current content cannot be preset" — exactly what the reporter
|
||||
// sees. The contradiction at the heart of the bug: the location is a
|
||||
// perfectly resolvable Spotify URI, but the speaker still refuses
|
||||
// to expose it as presetable.
|
||||
//
|
||||
// What this test locks in:
|
||||
//
|
||||
// - The /now_playing payload AfterTouch reads from a Spotify
|
||||
// Connect session has IsPresetable=false, despite a non-empty
|
||||
// location.
|
||||
// - The location field, base64-URL-decoded, yields a recognisable
|
||||
// `spotify:` URI. The contradiction is preserved verbatim so we
|
||||
// don't accidentally "fix" the test by stripping the location.
|
||||
//
|
||||
// When AfterTouch grows logic to override the IsPresetable signal
|
||||
// for Spotify Connect (e.g. a CLI --force flag, or service-side
|
||||
// resolution to the device's own Spotify integration), the assertion
|
||||
// here stays sound — it tests what the device emits, not what the
|
||||
// CLI decides — but a sibling test should assert the new fallback
|
||||
// path produces a successful preset.
|
||||
//
|
||||
// Pattern mirrors pkg/service/setup/issue218_regression_test.go.
|
||||
func TestIssue235_SpotifyConnectNowPlayingReportsNotPresetable(t *testing.T) {
|
||||
npXML, err := os.ReadFile(filepath.Join("testdata", "issue235", "now_playing.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read issue235 now_playing fixture: %v", err)
|
||||
}
|
||||
|
||||
// Fixture sanity: the SpotifyConnectUserName marker and
|
||||
// isPresetable=false are the load-bearing parts.
|
||||
if !strings.Contains(string(npXML), "SpotifyConnectUserName") {
|
||||
t.Fatalf("fixture missing SpotifyConnectUserName marker; got:\n%s", npXML)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(npXML), `isPresetable="false"`) {
|
||||
t.Fatalf("fixture missing isPresetable=\"false\"; got:\n%s", npXML)
|
||||
}
|
||||
|
||||
s, err := fakespeaker.Start(fakespeaker.Config{
|
||||
FixtureOverrides: map[string][]byte{
|
||||
"/now_playing": npXML,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start fakespeaker: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_ = s.Stop(ctx)
|
||||
})
|
||||
|
||||
// fakespeaker.HTTPAddr() returns "127.0.0.1:<port>"; client.NewClientFromHost
|
||||
// accepts the host:port form directly and routes /now_playing to it.
|
||||
c := client.NewClientFromHost(s.HTTPAddr())
|
||||
|
||||
now, err := c.GetNowPlaying()
|
||||
if err != nil {
|
||||
t.Fatalf("GetNowPlaying: %v", err)
|
||||
}
|
||||
|
||||
if now.ContentItem == nil {
|
||||
t.Fatalf("ContentItem is nil; full now_playing:\n%+v", now)
|
||||
}
|
||||
|
||||
// The bug's defining signal: false despite a non-empty location.
|
||||
if now.ContentItem.IsPresetable {
|
||||
t.Errorf("ContentItem.IsPresetable = true, want false — the Spotify Connect contradiction was 'fixed' on the device side; review whether the CLI's storeCurrentPreset still needs the IsPresetable gate")
|
||||
}
|
||||
|
||||
if now.ContentItem.Location == "" {
|
||||
t.Errorf("ContentItem.Location is empty, want a Spotify URI — fixture has drifted from the issue payload")
|
||||
}
|
||||
|
||||
if now.Source != "SPOTIFY" {
|
||||
t.Errorf("Source = %q, want SPOTIFY", now.Source)
|
||||
}
|
||||
|
||||
if now.SourceAccount != "SpotifyConnectUserName" {
|
||||
t.Errorf("SourceAccount = %q, want SpotifyConnectUserName (the Spotify Connect marker)", now.SourceAccount)
|
||||
}
|
||||
|
||||
// Surface the contradiction: the location decodes to a real Spotify URI,
|
||||
// so the IsPresetable=false is purely a device-side policy. Decode the
|
||||
// path-segment that follows `/playback/container/`.
|
||||
const containerPrefix = "/playback/container/"
|
||||
|
||||
segment := strings.TrimPrefix(now.ContentItem.Location, containerPrefix)
|
||||
if segment == now.ContentItem.Location {
|
||||
t.Logf("note: location does not match /playback/container/<base64> shape (was %q); not decoding", now.ContentItem.Location)
|
||||
return
|
||||
}
|
||||
|
||||
decoded, err := base64.URLEncoding.DecodeString(segment)
|
||||
if err != nil {
|
||||
decoded, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(segment, "="))
|
||||
if err != nil {
|
||||
t.Logf("note: location segment %q is not base64-URL-decodable: %v", segment, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(string(decoded), "spotify:") {
|
||||
t.Errorf("decoded location %q does not look like a spotify: URI; fixture may have drifted", decoded)
|
||||
}
|
||||
}
|
||||
@@ -185,3 +185,56 @@ func TestGetMigrationSummary_TelnetSucceedsSSHSucceeds(t *testing.T) {
|
||||
t.Errorf("TelnetVerifiedConfig = %q, want %q", summary.TelnetVerifiedConfig, target)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetMigrationSummary_TelnetOnlyMigrationDetected pins the ordering
|
||||
// bug fixed in PR #294 / issue #293.
|
||||
//
|
||||
// Before the fix, GetMigrationSummary called checkIsMigratedFromProbe
|
||||
// before draining the telnet goroutine's result, so
|
||||
// summary.TelnetVerifiedConfig was empty when isTelnetMigrated read it
|
||||
// — and the telnet axis was always reported false. For speakers
|
||||
// migrated *only* via telnet (envswitch flip; no SSH XML rewrite,
|
||||
// no DNS hook, no CA install), this misclassification meant
|
||||
// summary.IsMigrated was false despite the speaker actually pointing
|
||||
// at AfterTouch. The CLI's `setup verify` exited non-zero, and the
|
||||
// web UI rendered "Not Migrated".
|
||||
//
|
||||
// The fix moves m.checkIsMigratedFromProbe(summary, probe) to run
|
||||
// *after* the <-telnetCh drain, so TelnetVerifiedConfig is populated
|
||||
// when isTelnetMigrated inspects it.
|
||||
//
|
||||
// The scenario here matches foob61451's 2026-05-16 #293 reproducer:
|
||||
// SSH unavailable / disabled (every axis false), telnet getpdo reports
|
||||
// the AfterTouch host, no other migration path applied.
|
||||
func TestGetMigrationSummary_TelnetOnlyMigrationDetected(t *testing.T) {
|
||||
target := "http://example:8000"
|
||||
ft := &fakeTelnet{
|
||||
banner: "BoseShell\n-> ",
|
||||
responses: map[string]string{
|
||||
"getpdo CurrentSystemConfiguration": "margeServerUrl=" + target + "\n",
|
||||
},
|
||||
}
|
||||
|
||||
m, host, cleanup := telnetSummaryEnv(t, nil, ft)
|
||||
defer cleanup()
|
||||
|
||||
summary, err := m.GetMigrationSummary(host, "", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMigrationSummary: %v", err)
|
||||
}
|
||||
|
||||
// Pre-condition for the test to be meaningful: the telnet probe
|
||||
// must have populated TelnetVerifiedConfig. Without this, the
|
||||
// downstream assertions could pass trivially.
|
||||
if !strings.Contains(summary.TelnetVerifiedConfig, target) {
|
||||
t.Fatalf("setup: TelnetVerifiedConfig = %q, want it to contain %q", summary.TelnetVerifiedConfig, target)
|
||||
}
|
||||
|
||||
if !summary.TelnetMigrated {
|
||||
t.Errorf("TelnetMigrated = false, want true — telnet getpdo reports %q which matches Manager.ServerURL host. Likely regression of PR #294 ordering fix in GetMigrationSummary.", target)
|
||||
}
|
||||
|
||||
if !summary.IsMigrated {
|
||||
t.Errorf("IsMigrated = false, want true — telnet axis should carry IsMigrated when SSH-driven axes are false. Likely regression of PR #294 ordering fix.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
@@ -91,7 +92,18 @@ type MigrationSummary struct {
|
||||
// flag pairing as a precondition independently of the URL flip.
|
||||
IsPaired bool `json:"is_paired"`
|
||||
|
||||
ResolveIPError string `json:"resolve_ip_error,omitempty"`
|
||||
ResolveIPError string `json:"resolve_ip_error,omitempty"`
|
||||
// ResolveIPSource records where the resolved IP came from:
|
||||
// "device" — authoritative answer via SSH ping (preferred for
|
||||
// migration). "service" — service-side DNS lookup (fast but may
|
||||
// differ when NAT or split-DNS is in play). Empty when host was
|
||||
// already an IP literal or could not be resolved at all.
|
||||
ResolveIPSource string `json:"resolve_ip_source,omitempty"`
|
||||
// ResolveIPDurationMS measures how long the resolve call took
|
||||
// (wall-clock, milliseconds). Captured during preflight so we can
|
||||
// observe the SSH-ping cost in the wild.
|
||||
ResolveIPDurationMS int64 `json:"resolve_ip_duration_ms,omitempty"`
|
||||
|
||||
MirrorEnabled bool `json:"mirror_enabled"`
|
||||
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
|
||||
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"`
|
||||
@@ -322,17 +334,21 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
|
||||
|
||||
summary.PlannedConfig = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" + string(xmlContent)
|
||||
|
||||
// 2b. Planned network config (hosts entries, resolv.conf preview, resolve error)
|
||||
m.populatePlannedNetworkConfig(summary, deviceIP, targetURL)
|
||||
// 2b. Planned network config (hosts entries, resolv.conf preview, resolve error).
|
||||
// Pass an SSH client only when the probe succeeded — opening a fresh
|
||||
// dial when we already know SSH is dead would burn ~handshake-timeout
|
||||
// of wall time per refresh.
|
||||
var resolveClient SSHClient
|
||||
if probe.SSHOK && m.NewSSH != nil {
|
||||
resolveClient = m.NewSSH(deviceIP)
|
||||
}
|
||||
|
||||
m.populatePlannedNetworkConfig(summary, deviceIP, targetURL, resolveClient)
|
||||
|
||||
// 3. Provide HTTPS URL for testing (consumed by the migration UI)
|
||||
summary.ServerHTTPSURL = m.buildServerHTTPSURL(targetURL)
|
||||
|
||||
// 4. Check if migrated (telnet axis uses the parallel preflight;
|
||||
// XML/hosts/resolv axes use the probe data already gathered above).
|
||||
m.checkIsMigratedFromProbe(summary, probe)
|
||||
|
||||
// 7. Mirroring settings
|
||||
// 4. Mirroring settings
|
||||
if m.DataStore != nil {
|
||||
settings, err := m.DataStore.GetSettings()
|
||||
if err == nil {
|
||||
@@ -343,21 +359,26 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Merge telnet preflight results (started in parallel at the top).
|
||||
// 5. Merge telnet preflight results (started in parallel at the top).
|
||||
telnetResult := <-telnetCh
|
||||
summary.TelnetReachable = telnetResult.TelnetReachable
|
||||
summary.TelnetBanner = telnetResult.TelnetBanner
|
||||
summary.TelnetVerifiedConfig = telnetResult.TelnetVerifiedConfig
|
||||
summary.TelnetProbeError = telnetResult.TelnetProbeError
|
||||
|
||||
// 9. Cross-check SSH-XML and telnet-getpdo readings; surface any
|
||||
// 6. Check if migrated (must run after telnet results are merged so
|
||||
// TelnetVerifiedConfig is populated). XML/hosts/resolv axes use the
|
||||
// probe data already gathered above.
|
||||
m.checkIsMigratedFromProbe(summary, probe)
|
||||
|
||||
// 7. Cross-check SSH-XML and telnet-getpdo readings; surface any
|
||||
// divergence as a non-fatal warning.
|
||||
m.crossCheckPreflights(summary)
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, _, targetURL string) {
|
||||
func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, _, targetURL string, sshClient SSHClient) {
|
||||
parsedURL, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -368,14 +389,42 @@ func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, _, tar
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve locally only. The "from-device" lookup that resolveIP can
|
||||
// do via SSH (`ping -c 1 host`) costs another fresh SSH handshake
|
||||
// plus the ping's own runtime — easily 2–5 s on firmware-27 devices
|
||||
// — and the result feeds only the PlannedResolv/PlannedHosts preview.
|
||||
// For the actual apply paths (migrateViaHosts/migrateViaResolv) the
|
||||
// device-side resolution is still used; this is only the preview.
|
||||
hostIP, resolveErr := m.resolveIP(hostName, nil)
|
||||
if resolveErr != nil {
|
||||
// Resolve the target hostname. When the caller provides an SSH
|
||||
// client (i.e. the speaker already answered the probe), we prefer
|
||||
// the device-side lookup — it's authoritative for the actual
|
||||
// network path the speaker will use. When SSH isn't available we
|
||||
// fall back to service-side DNS and tag the result with
|
||||
// ErrResolvedFromServiceOnly so the summary can render it as
|
||||
// informational rather than as a hard error.
|
||||
//
|
||||
// Historical note: the SSH path was previously skipped here for
|
||||
// cost reasons (a comment claimed "2–5 s extra per preflight
|
||||
// refresh"). Measured 2026-05-16 across ST10 + ST20 on firmware
|
||||
// 27.0.6.46330.5043500: ~290 ms ± 10 ms per resolve, three runs.
|
||||
// Well under the original estimate — promoted to the default path.
|
||||
// ResolveIPDurationMS stays on the summary so any regression
|
||||
// (firmware upgrade, slower kex, etc.) is visible.
|
||||
start := time.Now()
|
||||
hostIP, resolveErr := m.resolveIP(hostName, sshClient)
|
||||
summary.ResolveIPDurationMS = time.Since(start).Milliseconds()
|
||||
|
||||
switch {
|
||||
case resolveErr == nil && hostIP != "":
|
||||
summary.ResolveIPSource = "device"
|
||||
if sshClient == nil {
|
||||
// Caller didn't ask for the SSH path, and we got a clean
|
||||
// answer — that only happens when host was already an IP
|
||||
// literal. Source is neither "device" nor "service" in a
|
||||
// meaningful sense; leave it empty.
|
||||
summary.ResolveIPSource = ""
|
||||
}
|
||||
case errors.Is(resolveErr, ErrResolvedFromServiceOnly):
|
||||
summary.ResolveIPSource = "service"
|
||||
// Sentinel-tagged errors are informational — the resolved IP
|
||||
// is still usable for the preview, the caller just shouldn't
|
||||
// treat it as authoritative. We do NOT populate ResolveIPError
|
||||
// here; the CLI/UI use that field for hard failures only.
|
||||
case resolveErr != nil:
|
||||
summary.ResolveIPError = resolveErr.Error()
|
||||
}
|
||||
|
||||
@@ -1180,6 +1229,22 @@ func (m *Manager) TrustCACert(deviceIP string) (string, error) {
|
||||
// the speaker's shared trust store. Identical to TrustCACert except the
|
||||
// cert bytes come from the caller — used by the remote CLI which fetches
|
||||
// /setup/ca.crt over HTTP and never touches Manager.Crypto.
|
||||
//
|
||||
// The write path is two-phase to keep the live bundle never half-written:
|
||||
//
|
||||
// 1. Upload the modified bundle to <bundlePath>.aftertouch.tmp (a sibling
|
||||
// on the same filesystem, so the same rw remount covers it).
|
||||
// 2. Read the tmp back over SSH, validate that every PEM block parses
|
||||
// and that the AfterTouch CA sentinel brackets exactly one certificate,
|
||||
// then atomically rename the tmp into place via `mv`. On any failure
|
||||
// between steps 1 and 2 the tmp is unlinked and the live bundle is
|
||||
// untouched — there is no rollback semantics to reason about.
|
||||
//
|
||||
// The .original backup written on first install is retained as
|
||||
// defense-in-depth (a user can manually restore from it if anything outside
|
||||
// this code path corrupts the live bundle), but it is no longer the
|
||||
// primary safety net for our own writes. See issue #262 for the original
|
||||
// failure-mode reporter.
|
||||
func (m *Manager) TrustCACertFromBytes(deviceIP string, caCertPEM []byte) (string, error) {
|
||||
if !strings.Contains(string(caCertPEM), "BEGIN CERTIFICATE") {
|
||||
return "", fmt.Errorf("CA payload does not contain a PEM certificate")
|
||||
@@ -1191,6 +1256,7 @@ func (m *Manager) TrustCACertFromBytes(deviceIP string, caCertPEM []byte) (strin
|
||||
var logs string
|
||||
|
||||
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
|
||||
tmpPath := bundlePath + ".aftertouch.tmp"
|
||||
out, _ := client.Run(rwCmd)
|
||||
logs += rwCmd + ": " + out + "\n"
|
||||
|
||||
@@ -1210,27 +1276,21 @@ func (m *Manager) TrustCACertFromBytes(deviceIP string, caCertPEM []byte) (strin
|
||||
|
||||
if strings.Contains(bundleContent, CALabel) {
|
||||
// Rebuild the bundle without our previously-injected CA so the
|
||||
// fresh one replaces the old.
|
||||
lines := strings.Split(bundleContent, "\n")
|
||||
// fresh one replaces the old. Older AfterTouch releases are
|
||||
// reported to have appended the CA on every install without
|
||||
// stripping the previous one, so live bundles can carry
|
||||
// several stale copies — stripAfterTouchEntries collapses
|
||||
// them all and reports the count so we can log a single line
|
||||
// of cleanup rather than failing validation.
|
||||
stripped := stripAfterTouchEntries(bundleContent)
|
||||
bundleContent = stripped.CleanedBundle
|
||||
|
||||
var newLines []string
|
||||
|
||||
inOurCA := false
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, CALabel) {
|
||||
inOurCA = !inOurCA
|
||||
continue
|
||||
}
|
||||
|
||||
if !inOurCA {
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
if stripped.RemovedEntries > 1 {
|
||||
logs += fmt.Sprintf("Cleaned up %d duplicate AfterTouch CA entries from existing bundle\n", stripped.RemovedEntries)
|
||||
}
|
||||
|
||||
bundleContent = strings.Join(newLines, "\n")
|
||||
if bundleContent != "" && !strings.HasSuffix(bundleContent, "\n") {
|
||||
bundleContent += "\n"
|
||||
if stripped.UnpairedSentinel {
|
||||
logs += "Warning: existing bundle had an unpaired AfterTouch sentinel; content after it was dropped along with the orphan. If anything legitimate was after the sentinel, restore from " + bundlePath + ".original.\n"
|
||||
}
|
||||
} else if bundleContent != "" && !strings.HasSuffix(bundleContent, "\n") {
|
||||
bundleContent += "\n"
|
||||
@@ -1239,11 +1299,55 @@ func (m *Manager) TrustCACertFromBytes(deviceIP string, caCertPEM []byte) (strin
|
||||
labeledCert := fmt.Sprintf("\n%s\n%s%s\n", CALabel, string(caCertPEM), CALabel)
|
||||
newBundleContent := bundleContent + labeledCert
|
||||
|
||||
if err := client.UploadContent([]byte(newBundleContent), bundlePath); err != nil {
|
||||
return logs, fmt.Errorf("failed to update bundle: %w", err)
|
||||
// Pre-upload validation: catch construction-time bugs (mangled PEM,
|
||||
// missing sentinel, etc.) before any SSH write. The live bundle is
|
||||
// untouched at this point.
|
||||
if _, vErr := validateCABundleBytes([]byte(newBundleContent)); vErr != nil {
|
||||
return logs, fmt.Errorf("constructed bundle failed validation, live bundle untouched: %w", vErr)
|
||||
}
|
||||
|
||||
logs += "Uploaded updated bundle to " + bundlePath + "\n"
|
||||
if vErr := validateAfterTouchLabelBracketing([]byte(newBundleContent)); vErr != nil {
|
||||
return logs, fmt.Errorf("constructed bundle has malformed AfterTouch sentinel, live bundle untouched: %w", vErr)
|
||||
}
|
||||
|
||||
// Phase 1: upload to a sibling tmp file on the same filesystem.
|
||||
if err := client.UploadContent([]byte(newBundleContent), tmpPath); err != nil {
|
||||
return logs, fmt.Errorf("failed to upload bundle to %s: %w", tmpPath, err)
|
||||
}
|
||||
|
||||
logs += "Uploaded candidate bundle to " + tmpPath + "\n"
|
||||
|
||||
// Phase 2: read the tmp back and verify the bytes survived transport.
|
||||
// On any failure here, unlink the tmp; the live bundle was never
|
||||
// touched, so no rollback is required.
|
||||
verifyContent, verifyErr := client.Run(fmt.Sprintf("cat %s", tmpPath))
|
||||
if verifyErr != nil {
|
||||
_, _ = client.Run(fmt.Sprintf("rm -f %s", tmpPath))
|
||||
return logs, fmt.Errorf("failed to read back candidate bundle %s for verification, live bundle untouched: %w", tmpPath, verifyErr)
|
||||
}
|
||||
|
||||
if _, vErr := validateCABundleBytes([]byte(verifyContent)); vErr != nil {
|
||||
_, _ = client.Run(fmt.Sprintf("rm -f %s", tmpPath))
|
||||
return logs, fmt.Errorf("verification of %s failed (post-upload PEM parse), live bundle untouched: %w", tmpPath, vErr)
|
||||
}
|
||||
|
||||
if vErr := validateAfterTouchLabelBracketing([]byte(verifyContent)); vErr != nil {
|
||||
_, _ = client.Run(fmt.Sprintf("rm -f %s", tmpPath))
|
||||
return logs, fmt.Errorf("verification of %s failed (post-upload sentinel bracketing), live bundle untouched: %w", tmpPath, vErr)
|
||||
}
|
||||
|
||||
logs += "Verified candidate bundle at " + tmpPath + "\n"
|
||||
|
||||
// Atomic replace. On the device's local filesystem this is a
|
||||
// rename(2) — observers see either the pre- or post-bundle, never
|
||||
// a half-written one.
|
||||
mvCmd := fmt.Sprintf("mv %s %s", tmpPath, bundlePath)
|
||||
if mvOut, mvErr := client.Run(mvCmd); mvErr != nil {
|
||||
_, _ = client.Run(fmt.Sprintf("rm -f %s", tmpPath))
|
||||
return logs, fmt.Errorf("failed to atomically replace bundle (%s -> %s, output=%q): %w", tmpPath, bundlePath, mvOut, mvErr)
|
||||
}
|
||||
|
||||
logs += mvCmd + "\n"
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
@@ -2332,11 +2436,20 @@ func (m *Manager) GetResolvedIP(host string) string {
|
||||
return ip
|
||||
}
|
||||
|
||||
// ErrResolvedFromServiceOnly is returned (wrapped) by resolveIP when the
|
||||
// service-side DNS fallback produced an IP but the device-side SSH ping
|
||||
// either wasn't attempted or didn't yield a usable result. The error
|
||||
// carries the resolved IP — callers that don't need an authoritative
|
||||
// device-side answer (preview/summary builders) can errors.Is()-check
|
||||
// and treat the IP as informational. Apply-path callers that DO need
|
||||
// authoritative resolution can bail.
|
||||
var ErrResolvedFromServiceOnly = errors.New("resolved from service, not from device")
|
||||
|
||||
// resolveIP resolves a hostname to an IP address.
|
||||
// It first tries to resolve from the device via SSH ping (authoritative for migration).
|
||||
// If that fails, it falls back to resolving from the service itself.
|
||||
// An error is returned whenever the SSH ping did not produce the IP, so callers that
|
||||
// write config to the device can abort rather than risk writing an unresolvable hostname.
|
||||
// If that fails, it falls back to resolving from the service itself, returning the
|
||||
// resolved IP wrapped with ErrResolvedFromServiceOnly so callers can distinguish
|
||||
// "authoritative device-side answer" from "best-effort service-side fallback".
|
||||
func (m *Manager) resolveIP(host string, client SSHClient) (string, error) {
|
||||
if net.ParseIP(host) != nil {
|
||||
return host, nil
|
||||
@@ -2382,7 +2495,8 @@ func (m *Manager) resolveIP(host string, client SSHClient) (string, error) {
|
||||
resolved = ips[0].String()
|
||||
}
|
||||
|
||||
return resolved, fmt.Errorf("resolved %q to %s from service, not from device — result may be wrong if NAT or split-DNS is in use", host, resolved)
|
||||
return resolved, fmt.Errorf("%w: %q → %s (NAT or split-DNS may differ from what the device would see)",
|
||||
ErrResolvedFromServiceOnly, host, resolved)
|
||||
}
|
||||
|
||||
// SyncDeviceData fetches presets, recents and sources from the device and saves them to the datastore.
|
||||
@@ -2435,7 +2549,16 @@ func (m *Manager) SyncDeviceData(deviceIP string) error {
|
||||
// 4. Fetch Sources
|
||||
m.syncSources(deviceIP, accountID, deviceID)
|
||||
|
||||
// 5. Create off-device backup of system configuration
|
||||
// 5. Nudge the device to re-render its source list. After a factory
|
||||
// reset (issue #234) the speaker's /sources only lists the always-on
|
||||
// local entries until it receives a <sourcesUpdated/> notification;
|
||||
// the reporter's workaround was to POST this by hand. Wiring it into
|
||||
// the sync flow means the user gets the visible recovery for free
|
||||
// after they click Data Sync — re-pairing (which the wizard already
|
||||
// detects + prompts for) is the orthogonal half of the fix.
|
||||
m.notifySpeakerSourcesUpdated(deviceIP, deviceID)
|
||||
|
||||
// 6. Create off-device backup of system configuration
|
||||
_ = m.BackupConfigOffDevice(deviceIP)
|
||||
|
||||
return nil
|
||||
@@ -2611,3 +2734,33 @@ func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
|
||||
_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, configuredSources)
|
||||
}
|
||||
}
|
||||
|
||||
// notifySpeakerSourcesUpdated POSTs the <sourcesUpdated/> notification
|
||||
// to /notification on the device, mirroring the manual workaround
|
||||
// documented in issue #234. The device responds by re-evaluating its
|
||||
// /sources catalogue — after a factory reset that's what makes TUNEIN /
|
||||
// LOCAL_INTERNET_RADIO / DEEZER / linked Spotify accounts reappear in
|
||||
// the list. The wizard's pair-account flow restores playback (it
|
||||
// recreates the Marge.xml token); this nudge restores the *visible*
|
||||
// source list. Both are needed for a full #234 recovery; this is the
|
||||
// half AfterTouch can automate without user input.
|
||||
//
|
||||
// Delegates the HTTP plumbing to pkg/client.Client.NotifySourcesUpdated,
|
||||
// which is the same path handlers_mgmt.go uses after music-service
|
||||
// account changes — keeping the wire-shape definition in one place
|
||||
// (pkg/models.NewSourcesUpdatedNotification).
|
||||
//
|
||||
// Fire-and-forget: a network failure (or the device returning an
|
||||
// unexpected response) doesn't fail the surrounding sync. The sync's
|
||||
// persisted state is already on disk by the time we fire the
|
||||
// notification; whether the device acts on it is observable on the
|
||||
// next sync.
|
||||
func (m *Manager) notifySpeakerSourcesUpdated(deviceIP, deviceID string) {
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
if err := c.NotifySourcesUpdated(deviceID); err != nil {
|
||||
log.Printf("[SYNC] notify %s: %v", deviceIP, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[SYNC] notify %s sourcesUpdated -> ok", deviceIP)
|
||||
}
|
||||
|
||||
@@ -21,8 +21,53 @@ const (
|
||||
// LanguageEnglish is the sysLanguage code for English. ‹2› is the
|
||||
// value the official Bose app sends during English-locale setup.
|
||||
LanguageEnglish = 2
|
||||
|
||||
// DefaultMargeAuthToken is the placeholder userAuthToken sent in
|
||||
// <PairDeviceWithAccount> when the caller didn't supply one. The
|
||||
// speaker accepts any non-empty value; a real Bose-issued token
|
||||
// shape (128-char base64 per docs/reference/DEVICE-PAIRING-FLOW.md
|
||||
// line 154) is not required — verified during #195 investigation
|
||||
// where the speaker happily persisted "Bearer AfterTouch" and
|
||||
// re-derived its post-pair state from the marge endpoints
|
||||
// regardless of token content.
|
||||
DefaultMargeAuthToken = "Bearer AfterTouch"
|
||||
|
||||
// DefaultMargePairingEmail is the synthetic accountEmail used when
|
||||
// PairingExtras requests the extended <PairDeviceWithAccount> payload
|
||||
// but doesn't supply an email. RFC 2606 reserves ".invalid" as a TLD
|
||||
// guaranteed never to resolve, which is what we want here — the
|
||||
// speaker writes it into its persistent state but no real address
|
||||
// receives anything.
|
||||
DefaultMargePairingEmail = "local@aftertouch.invalid"
|
||||
)
|
||||
|
||||
// MargePairingExtras carries the optional fields that the official Bose
|
||||
// Android app and Zimbo88's USB-less OpenCloudTouch script include in
|
||||
// their <PairDeviceWithAccount> payloads. AfterTouch historically sent
|
||||
// only <accountId> + <userAuthToken>; that minimal shape is the
|
||||
// suspected trigger for the post-pair AUX/preset breakage tracked in
|
||||
// issues #195 and #269.
|
||||
//
|
||||
// Set BoseServer (and optionally UpdateServer/AccountEmail) on the
|
||||
// SessionConfig to opt into the richer payload. Empty fields are
|
||||
// omitted from the XML so callers can choose any subset.
|
||||
//
|
||||
// Reference: docs/reference/DEVICE-PAIRING-FLOW.md and
|
||||
// https://github.com/scheilch/opencloudtouch/discussions/201.
|
||||
type MargePairingExtras struct {
|
||||
// BoseServer is the marge server URL the speaker should use after
|
||||
// pairing. Typically equal to AfterTouch's service URL.
|
||||
BoseServer string
|
||||
// UpdateServer is the firmware-update server URL. If empty and
|
||||
// BoseServer is set, SetMargeAccount derives it as
|
||||
// BoseServer + "/updates/soundtouch".
|
||||
UpdateServer string
|
||||
// AccountEmail is the synthetic email persisted alongside the
|
||||
// account. If empty and BoseServer is set, SetMargeAccount fills
|
||||
// in DefaultMargePairingEmail.
|
||||
AccountEmail string
|
||||
}
|
||||
|
||||
// StateMachine is the surface the InitPlan orchestrator drives. The
|
||||
// concrete WebSocket-backed implementation is *Session; tests inject
|
||||
// an in-memory fake via Manager.NewSession.
|
||||
@@ -52,6 +97,11 @@ type SessionConfig struct {
|
||||
WSScheme string
|
||||
// WSPort overrides 8080 when deviceIP does not already carry a port.
|
||||
WSPort int
|
||||
// PairingExtras opts the session into the richer
|
||||
// <PairDeviceWithAccount> payload (boseServer / updateServer /
|
||||
// accountEmail) used by the official Bose Android app. Zero value
|
||||
// retains the historical minimal payload.
|
||||
PairingExtras MargePairingExtras
|
||||
}
|
||||
|
||||
// Session is a synchronous request/response WebSocket session driving
|
||||
@@ -60,10 +110,11 @@ type SessionConfig struct {
|
||||
// and stateful) — setup is a short, linear sequence and benefits from a
|
||||
// purpose-built transport.
|
||||
type Session struct {
|
||||
deviceID string
|
||||
conn *websocket.Conn
|
||||
reqID atomic.Int64
|
||||
stepTimeout time.Duration
|
||||
deviceID string
|
||||
conn *websocket.Conn
|
||||
reqID atomic.Int64
|
||||
stepTimeout time.Duration
|
||||
pairingExtras MargePairingExtras
|
||||
}
|
||||
|
||||
// DialSession opens a WebSocket to the speaker at deviceIP and
|
||||
@@ -117,7 +168,12 @@ func DialSession(deviceIP, deviceID string, cfg SessionConfig) (*Session, error)
|
||||
step = defaultSetupStepTimeout
|
||||
}
|
||||
|
||||
return &Session{deviceID: deviceID, conn: conn, stepTimeout: step}, nil
|
||||
return &Session{
|
||||
deviceID: deviceID,
|
||||
conn: conn,
|
||||
stepTimeout: step,
|
||||
pairingExtras: cfg.PairingExtras,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close sends a normal-closure frame and closes the underlying socket.
|
||||
@@ -243,24 +299,55 @@ func (s *Session) SetName(ctx context.Context, name string) error {
|
||||
}
|
||||
|
||||
// SetMargeAccount sends the canonical PairDeviceWithAccount envelope.
|
||||
// authToken defaults to "Bearer aftertouch" when empty — our local
|
||||
// service does not validate it, but a non-empty value matches the
|
||||
// official app's shape.
|
||||
// authToken defaults to DefaultMargeAuthToken when empty.
|
||||
//
|
||||
// If SessionConfig.PairingExtras.BoseServer is set, the payload is
|
||||
// extended with <boseServer>, <updateServer>, and <accountEmail>
|
||||
// matching the official Bose app's shape (and Zimbo88's OpenCloudTouch
|
||||
// USB-less script). UpdateServer and AccountEmail derive from
|
||||
// BoseServer when not explicitly set.
|
||||
func (s *Session) SetMargeAccount(ctx context.Context, accountID, authToken string) error {
|
||||
if accountID == "" {
|
||||
return errors.New("SetMargeAccount: accountID is required")
|
||||
}
|
||||
|
||||
if authToken == "" {
|
||||
authToken = "Bearer aftertouch"
|
||||
authToken = DefaultMargeAuthToken
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(
|
||||
`<PairDeviceWithAccount><accountId>%s</accountId><userAuthToken>%s</userAuthToken></PairDeviceWithAccount>`,
|
||||
xmlBodyEscape(accountID), xmlBodyEscape(authToken),
|
||||
)
|
||||
return s.sendStep(ctx, "setMargeAccount", "POST", buildPairDeviceWithAccountXML(accountID, authToken, s.pairingExtras))
|
||||
}
|
||||
|
||||
return s.sendStep(ctx, "setMargeAccount", "POST", body)
|
||||
// buildPairDeviceWithAccountXML serializes the <PairDeviceWithAccount>
|
||||
// body. Extracted so tests can pin the exact shape without driving a
|
||||
// full WebSocket session.
|
||||
func buildPairDeviceWithAccountXML(accountID, authToken string, extras MargePairingExtras) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<PairDeviceWithAccount>`)
|
||||
b.WriteString(`<accountId>` + xmlBodyEscape(accountID) + `</accountId>`)
|
||||
b.WriteString(`<userAuthToken>` + xmlBodyEscape(authToken) + `</userAuthToken>`)
|
||||
|
||||
if extras.BoseServer != "" {
|
||||
b.WriteString(`<boseServer>` + xmlBodyEscape(extras.BoseServer) + `</boseServer>`)
|
||||
|
||||
updateServer := extras.UpdateServer
|
||||
if updateServer == "" {
|
||||
updateServer = strings.TrimRight(extras.BoseServer, "/") + "/updates/soundtouch"
|
||||
}
|
||||
|
||||
b.WriteString(`<updateServer>` + xmlBodyEscape(updateServer) + `</updateServer>`)
|
||||
|
||||
email := extras.AccountEmail
|
||||
if email == "" {
|
||||
email = DefaultMargePairingEmail
|
||||
}
|
||||
|
||||
b.WriteString(`<accountEmail>` + xmlBodyEscape(email) + `</accountEmail>`)
|
||||
}
|
||||
|
||||
b.WriteString(`</PairDeviceWithAccount>`)
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Leave sends SETUP_LEAVE.
|
||||
|
||||
@@ -184,7 +184,7 @@ func TestSession_SendsCanonicalEnvelopes(t *testing.T) {
|
||||
mustContain(t, frames[3], `<setupState state="SETUP_ENTER"/>`)
|
||||
mustContain(t, frames[4], `<setupState state="SETUP_IDENTIFY_DEVICE_LEAVE"/>`)
|
||||
mustContain(t, frames[5], `url="name"`, `<name>Living Room</name>`)
|
||||
mustContain(t, frames[6], `url="setMargeAccount"`, `<accountId>1234567</accountId>`, `<userAuthToken>Bearer aftertouch</userAuthToken>`)
|
||||
mustContain(t, frames[6], `url="setMargeAccount"`, `<accountId>1234567</accountId>`, `<userAuthToken>`+DefaultMargeAuthToken+`</userAuthToken>`)
|
||||
mustContain(t, frames[7], `<setupState state="SETUP_LEAVE"/>`)
|
||||
mustContain(t, frames[8], `url="pushCustomerSupportInfoToMarge"`, `method="GET"`)
|
||||
}
|
||||
@@ -301,6 +301,69 @@ func TestSession_XMLAttributeEscape(t *testing.T) {
|
||||
mustContain(t, frames[0], `deviceID="quoted"<id>"`)
|
||||
}
|
||||
|
||||
// TestBuildPairDeviceWithAccountXML pins both the minimal-payload
|
||||
// shape (historical AfterTouch behaviour) and the extended-payload
|
||||
// shape introduced for #195/#269 investigation. The extended path
|
||||
// mirrors what the official Bose app and Zimbo88's OpenCloudTouch
|
||||
// USB-less script send (see docs/reference/DEVICE-PAIRING-FLOW.md
|
||||
// and https://github.com/scheilch/opencloudtouch/discussions/201).
|
||||
func TestBuildPairDeviceWithAccountXML(t *testing.T) {
|
||||
t.Run("minimal payload — no extras", func(t *testing.T) {
|
||||
got := buildPairDeviceWithAccountXML("1234567", "Bearer tok", MargePairingExtras{})
|
||||
|
||||
want := `<PairDeviceWithAccount>` +
|
||||
`<accountId>1234567</accountId>` +
|
||||
`<userAuthToken>Bearer tok</userAuthToken>` +
|
||||
`</PairDeviceWithAccount>`
|
||||
if got != want {
|
||||
t.Errorf("\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extended payload — BoseServer triggers derived defaults", func(t *testing.T) {
|
||||
got := buildPairDeviceWithAccountXML(
|
||||
"1234567", "Bearer tok",
|
||||
MargePairingExtras{BoseServer: "https://soundtouch.local"},
|
||||
)
|
||||
|
||||
mustContain(t, got,
|
||||
`<boseServer>https://soundtouch.local</boseServer>`,
|
||||
`<updateServer>https://soundtouch.local/updates/soundtouch</updateServer>`,
|
||||
`<accountEmail>`+DefaultMargePairingEmail+`</accountEmail>`,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("extended payload — explicit UpdateServer + AccountEmail honoured", func(t *testing.T) {
|
||||
got := buildPairDeviceWithAccountXML(
|
||||
"1234567", "Bearer tok",
|
||||
MargePairingExtras{
|
||||
BoseServer: "https://example.test",
|
||||
UpdateServer: "https://updates.example.test/firmware",
|
||||
AccountEmail: "user@example.test",
|
||||
},
|
||||
)
|
||||
|
||||
mustContain(t, got,
|
||||
`<boseServer>https://example.test</boseServer>`,
|
||||
`<updateServer>https://updates.example.test/firmware</updateServer>`,
|
||||
`<accountEmail>user@example.test</accountEmail>`,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("extended payload — BoseServer trailing slash trimmed when deriving UpdateServer", func(t *testing.T) {
|
||||
got := buildPairDeviceWithAccountXML(
|
||||
"1234567", "Bearer tok",
|
||||
MargePairingExtras{BoseServer: "https://soundtouch.local/"},
|
||||
)
|
||||
|
||||
// Derived path uses TrimRight on BoseServer so we don't get
|
||||
// "soundtouch.local//updates/soundtouch".
|
||||
mustContain(t, got,
|
||||
`<updateServer>https://soundtouch.local/updates/soundtouch</updateServer>`,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func mustContain(t *testing.T, s string, needles ...string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package setup
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -18,6 +19,15 @@ import (
|
||||
type mockSSH struct {
|
||||
runFunc func(command string) (string, error)
|
||||
uploadContentFunc func(content []byte, remotePath string) error
|
||||
|
||||
// uploaded mirrors UploadContent calls so that a subsequent
|
||||
// `cat <path>` against a path that the test didn't explicitly
|
||||
// script via runFunc returns what we just wrote there. This is
|
||||
// what makes the tmp-then-mv flow in TrustCACertFromBytes work
|
||||
// against tests that only scripted the live-bundle path. Tests
|
||||
// that *do* script `cat <path>` keep priority — runFunc is
|
||||
// consulted first and the upload mirror is the fallback.
|
||||
uploaded map[string][]byte
|
||||
}
|
||||
|
||||
// probeScriptHeader is the first line of the batched probe script
|
||||
@@ -35,7 +45,24 @@ func (m *mockSSH) Run(command string) (string, error) {
|
||||
}
|
||||
|
||||
if m.runFunc != nil {
|
||||
return m.runFunc(command)
|
||||
out, err := m.runFunc(command)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
if out != "" {
|
||||
return out, nil
|
||||
}
|
||||
// runFunc returned ("", nil) — fall through to the upload
|
||||
// mirror so tmp readbacks that the test didn't script
|
||||
// explicitly still produce the bytes we just wrote there.
|
||||
}
|
||||
|
||||
if strings.HasPrefix(command, "cat ") {
|
||||
path := strings.TrimPrefix(command, "cat ")
|
||||
if body, ok := m.uploaded[path]; ok {
|
||||
return string(body), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
@@ -93,6 +120,12 @@ func (m *mockSSH) synthesizeProbeResponse(script string) (string, error) {
|
||||
}
|
||||
|
||||
func (m *mockSSH) UploadContent(content []byte, remotePath string) error {
|
||||
if m.uploaded == nil {
|
||||
m.uploaded = make(map[string][]byte)
|
||||
}
|
||||
|
||||
m.uploaded[remotePath] = append([]byte(nil), content...)
|
||||
|
||||
if m.uploadContentFunc != nil {
|
||||
return m.uploadContentFunc(content, remotePath)
|
||||
}
|
||||
@@ -703,6 +736,58 @@ func TestResolveIP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveIP_ServiceFallbackTagsSentinel pins the #282 fix:
|
||||
// service-side fallback returns the resolved IP wrapped with
|
||||
// ErrResolvedFromServiceOnly. Callers can errors.Is()-check the
|
||||
// sentinel to distinguish informational fallback from a hard
|
||||
// failure, which fixes the long-standing CLI/UI ❌ row that appeared
|
||||
// every time a hostname target was used with SSH off.
|
||||
func TestResolveIP_ServiceFallbackTagsSentinel(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
// No SSH client → forces the service-side fallback path.
|
||||
ip, err := m.resolveIP("localhost", nil)
|
||||
if ip != "127.0.0.1" && ip != "::1" {
|
||||
t.Fatalf("expected localhost to resolve service-side, got ip=%q err=%v", ip, err)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
t.Fatalf("expected service-side fallback to surface ErrResolvedFromServiceOnly, got nil err")
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrResolvedFromServiceOnly) {
|
||||
t.Errorf("expected error to wrap ErrResolvedFromServiceOnly, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveIP_DeviceSuccessReturnsNilError keeps the happy-path
|
||||
// guarantee explicit alongside the sentinel-tagging contract above.
|
||||
func TestResolveIP_DeviceSuccessReturnsNilError(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
mock := &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if strings.Contains(command, "ping -c 1 myhost") {
|
||||
return "PING myhost (10.0.0.5): 56 data bytes", nil
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
|
||||
ip, err := m.resolveIP("myhost", mock)
|
||||
if ip != "10.0.0.5" {
|
||||
t.Errorf("expected 10.0.0.5, got %s", ip)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("device-side success must return nil error, got %v", err)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrResolvedFromServiceOnly) {
|
||||
t.Errorf("device-side success must NOT carry ErrResolvedFromServiceOnly sentinel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateViaHosts_SkipCAIfTrusted(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "setup-test-skip-ca")
|
||||
if err != nil {
|
||||
@@ -780,6 +865,10 @@ func TestTrustCACert(t *testing.T) {
|
||||
return "", fmt.Errorf("file not found")
|
||||
}
|
||||
return "", nil
|
||||
// mockSSH automatically mirrors uploads back on
|
||||
// `cat <path>` when runFunc returns ("", nil), so the
|
||||
// post-upload tmp readback in TrustCACertFromBytes
|
||||
// works without test-side wiring.
|
||||
},
|
||||
uploadContentFunc: func(content []byte, remotePath string) error {
|
||||
uploadCalls = append(uploadCalls, remotePath)
|
||||
@@ -805,16 +894,267 @@ func TestTrustCACert(t *testing.T) {
|
||||
t.Errorf("Expected ca-bundle.crt backup")
|
||||
}
|
||||
|
||||
// Verify CA upload
|
||||
foundUpload := false
|
||||
// Verify CA upload landed on the tmp path (atomic-replace flow).
|
||||
foundTmpUpload := false
|
||||
for _, path := range uploadCalls {
|
||||
if path == "/etc/pki/tls/certs/ca-bundle.crt" {
|
||||
foundUpload = true
|
||||
if path == "/etc/pki/tls/certs/ca-bundle.crt.aftertouch.tmp" {
|
||||
foundTmpUpload = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundUpload {
|
||||
t.Errorf("Expected updated bundle to be uploaded to /etc/pki/tls/certs/ca-bundle.crt")
|
||||
|
||||
if !foundTmpUpload {
|
||||
t.Errorf("Expected candidate bundle to be uploaded to ca-bundle.crt.aftertouch.tmp; got upload paths: %v", uploadCalls)
|
||||
}
|
||||
|
||||
// Verify the live bundle was NOT touched directly by UploadContent —
|
||||
// the rename via Run() is the only path that touches the live file.
|
||||
for _, path := range uploadCalls {
|
||||
if path == "/etc/pki/tls/certs/ca-bundle.crt" {
|
||||
t.Errorf("UploadContent wrote directly to live bundle %s — atomic-replace flow expects tmp + mv only", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the atomic rename ran and that no rm of the tmp happened
|
||||
// (rm only fires on a verification failure).
|
||||
foundMv := false
|
||||
foundRm := false
|
||||
|
||||
for _, call := range runCalls {
|
||||
if call == "mv /etc/pki/tls/certs/ca-bundle.crt.aftertouch.tmp /etc/pki/tls/certs/ca-bundle.crt" {
|
||||
foundMv = true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(call, "rm -f /etc/pki/tls/certs/ca-bundle.crt.aftertouch.tmp") {
|
||||
foundRm = true
|
||||
}
|
||||
}
|
||||
|
||||
if !foundMv {
|
||||
t.Errorf("Expected atomic mv from .aftertouch.tmp to live bundle; got run calls: %v", runCalls)
|
||||
}
|
||||
|
||||
if foundRm {
|
||||
t.Errorf("Did not expect a cleanup rm on the happy path; got run calls: %v", runCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrustCACert_StripsMultipleStaleEntriesSilently pins the
|
||||
// behaviour the user flagged for AfterTouch installs that pre-date
|
||||
// the strip-then-append logic: live bundles in the field can carry
|
||||
// two or more copies of our CA from older releases that appended
|
||||
// without cleanup. The new install must:
|
||||
//
|
||||
// - strip every stale AfterTouch entry,
|
||||
// - log how many duplicates were cleaned up,
|
||||
// - append exactly one fresh entry,
|
||||
// - upload to the tmp path,
|
||||
// - verify and rename — i.e. the cleanup itself must not break the
|
||||
// validation or trigger the rollback path.
|
||||
func TestTrustCACert_StripsMultipleStaleEntriesSilently(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "trust-ca-multi-")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
t.Fatalf("Failed to ensure CA: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager("http://localhost:8000", nil, cm)
|
||||
|
||||
const (
|
||||
bundlePath = "/etc/pki/tls/certs/ca-bundle.crt"
|
||||
tmpPath = bundlePath + ".aftertouch.tmp"
|
||||
)
|
||||
|
||||
// Pre-existing bundle: one legitimate upstream cert plus two
|
||||
// stale AfterTouch entries from old installs. Generated inline
|
||||
// to stay self-contained.
|
||||
upstream := generatePEMCertificate(t, "upstream-root")
|
||||
stale1 := generatePEMCertificate(t, "aftertouch-stale-1")
|
||||
stale2 := generatePEMCertificate(t, "aftertouch-stale-2")
|
||||
preexisting := string(upstream) +
|
||||
CALabel + "\n" + string(stale1) + CALabel + "\n" +
|
||||
CALabel + "\n" + string(stale2) + CALabel + "\n"
|
||||
|
||||
runCalls := []string{}
|
||||
|
||||
var sshMock *mockSSH
|
||||
|
||||
m.NewSSH = func(_ string) SSHClient {
|
||||
sshMock = &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
runCalls = append(runCalls, command)
|
||||
|
||||
if strings.HasPrefix(command, "[ -f") {
|
||||
// .original doesn't exist yet → triggers initial backup
|
||||
return "", fmt.Errorf("file not found")
|
||||
}
|
||||
|
||||
if command == "cat "+bundlePath {
|
||||
return preexisting, nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
// Tmp readback falls through to mockSSH's upload mirror.
|
||||
},
|
||||
}
|
||||
|
||||
return sshMock
|
||||
}
|
||||
|
||||
logs, err := m.TrustCACert("192.168.1.10")
|
||||
if err != nil {
|
||||
t.Fatalf("TrustCACert failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(logs, "Cleaned up 2 duplicate AfterTouch CA entries") {
|
||||
t.Errorf("logs do not mention duplicate cleanup; got:\n%s", logs)
|
||||
}
|
||||
|
||||
uploaded, ok := sshMock.uploaded[tmpPath]
|
||||
if !ok {
|
||||
t.Fatalf("nothing uploaded to %s; only got: %v", tmpPath, uploadKeys(sshMock.uploaded))
|
||||
}
|
||||
|
||||
// The uploaded bundle must contain exactly two sentinels (open +
|
||||
// close) bracketing exactly one CERTIFICATE block, regardless of
|
||||
// how many stale entries the input had.
|
||||
if err := validateAfterTouchLabelBracketing(uploaded); err != nil {
|
||||
t.Errorf("uploaded bundle has malformed AfterTouch bracketing despite the cleanup: %v", err)
|
||||
}
|
||||
|
||||
// And the cleanup must not have dropped the legitimate upstream cert.
|
||||
count, err := validateCABundleBytes(uploaded)
|
||||
if err != nil {
|
||||
t.Fatalf("uploaded bundle does not validate: %v", err)
|
||||
}
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("uploaded bundle has %d CERTIFICATE blocks, want 2 (the upstream root + our fresh AfterTouch CA)", count)
|
||||
}
|
||||
|
||||
// Atomic rename should have fired, and no rollback rm.
|
||||
foundMv := false
|
||||
foundRm := false
|
||||
|
||||
for _, call := range runCalls {
|
||||
if call == "mv "+tmpPath+" "+bundlePath {
|
||||
foundMv = true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(call, "rm -f "+tmpPath) {
|
||||
foundRm = true
|
||||
}
|
||||
}
|
||||
|
||||
if !foundMv {
|
||||
t.Errorf("Expected atomic mv after cleanup; got run calls: %v", runCalls)
|
||||
}
|
||||
|
||||
if foundRm {
|
||||
t.Errorf("Cleanup path triggered rollback rm — multi-entry input should not be a failure case; got: %v", runCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func uploadKeys(m map[string][]byte) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
// TestTrustCACert_PostUploadVerificationFailureCleansUpTmp pins the
|
||||
// rollback-free recovery story from issue #262: when the tmp file's
|
||||
// readback doesn't validate (here we simulate transport truncation by
|
||||
// returning the tmp content stripped of its closing AfterTouch label),
|
||||
// the rename must NOT fire, the tmp must be removed, and the error
|
||||
// must name the verification failure plus reassure the caller the
|
||||
// live bundle wasn't touched.
|
||||
func TestTrustCACert_PostUploadVerificationFailureCleansUpTmp(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "trust-ca-fail-")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
t.Fatalf("Failed to ensure CA: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager("http://localhost:8000", nil, cm)
|
||||
|
||||
const (
|
||||
bundlePath = "/etc/pki/tls/certs/ca-bundle.crt"
|
||||
tmpPath = bundlePath + ".aftertouch.tmp"
|
||||
)
|
||||
|
||||
runCalls := []string{}
|
||||
|
||||
var sshMock *mockSSH
|
||||
|
||||
m.NewSSH = func(_ string) SSHClient {
|
||||
sshMock = &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
runCalls = append(runCalls, command)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(command, "[ -f"):
|
||||
return "", fmt.Errorf("file not found")
|
||||
case command == "cat "+tmpPath:
|
||||
// Simulate transport corruption: chop the closing
|
||||
// AfterTouch sentinel off the bytes mockSSH would
|
||||
// otherwise mirror back. Pre-upload validation
|
||||
// passed (the full bytes were well-formed), but
|
||||
// the readback doesn't bracket cleanly anymore.
|
||||
return strings.Replace(string(sshMock.uploaded[tmpPath]), "\n"+CALabel+"\n", "\n", 1), nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
|
||||
return sshMock
|
||||
}
|
||||
|
||||
_, err = m.TrustCACert("192.168.1.10")
|
||||
if err == nil {
|
||||
t.Fatalf("TrustCACert succeeded, want a verification failure")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "verification of "+tmpPath+" failed") {
|
||||
t.Errorf("error does not name the verification target: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "live bundle untouched") {
|
||||
t.Errorf("error does not reassure that the live bundle was untouched: %v", err)
|
||||
}
|
||||
|
||||
foundRm := false
|
||||
foundMv := false
|
||||
|
||||
for _, call := range runCalls {
|
||||
if call == "rm -f "+tmpPath {
|
||||
foundRm = true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(call, "mv "+tmpPath) {
|
||||
foundMv = true
|
||||
}
|
||||
}
|
||||
|
||||
if !foundRm {
|
||||
t.Errorf("Expected cleanup rm of %s after verification failure; got run calls: %v", tmpPath, runCalls)
|
||||
}
|
||||
|
||||
if foundMv {
|
||||
t.Errorf("mv ran despite verification failure — live bundle was overwritten with bad content; run calls: %v", runCalls)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="1575607101" updatedOn="1593644620">
|
||||
<ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/station?data=eyJuYW1lIjoiT1BCIiwiaW1hZ2VVcmwiOiIiLCJzdHJlYW1VcmwiOiJodHRwOi8vYWlzLXNhMy5jZG5zdHJlYW0xLmNvbS8yNDQwXzEyOC5hYWMifQ%3D%3D" sourceAccount="" isPresetable="true">
|
||||
<itemName>OPB</itemName>
|
||||
<containerArt></containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<info deviceID="DEADBEEFCAFE">
|
||||
<name>Factory-Reset SoundTouch 20</name>
|
||||
<type>SoundTouch 20</type>
|
||||
<margeAccountUUID></margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500</softwareVersion>
|
||||
<serialNumber>SN0000000000000000DEMO</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<margeURL>https://streaming.bose.com</margeURL>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>02:00:00:00:00:01</macAddress>
|
||||
<ipAddress>127.0.0.1</ipAddress>
|
||||
</networkInfo>
|
||||
<moduleType>sm2</moduleType>
|
||||
<variant>rhino</variant>
|
||||
<variantMode>normal</variantMode>
|
||||
<countryCode>GB</countryCode>
|
||||
<regionCode>GB</regionCode>
|
||||
</info>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<sources deviceID="DEADBEEFCAFE">
|
||||
<sourceItem source="AUX" sourceAccount="AUX" status="READY" isLocal="true" multiroomallowed="true">AUX IN</sourceItem>
|
||||
<sourceItem source="BLUETOOTH" status="UNAVAILABLE" isLocal="true" multiroomallowed="true"/>
|
||||
<sourceItem source="AIRPLAY" status="UNAVAILABLE" isLocal="false" multiroomallowed="false"/>
|
||||
<sourceItem source="SPOTIFY" sourceAccount="SpotifyConnectUserName" status="UNAVAILABLE" isLocal="false" multiroomallowed="true">SpotifyConnectUserName</sourceItem>
|
||||
<sourceItem source="NOTIFICATION" status="UNAVAILABLE" isLocal="false" multiroomallowed="true"/>
|
||||
<sourceItem source="QPLAY" sourceAccount="QPlay1UserName" status="UNAVAILABLE" isLocal="true" multiroomallowed="true">QPlay1UserName</sourceItem>
|
||||
</sources>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<nowPlaying deviceID="DEADBEEFCAFE" source="SPOTIFY" sourceAccount="SpotifyConnectUserName">
|
||||
<ContentItem source="SPOTIFY" sourceAccount="SpotifyConnectUserName" type="tracklisturl" location="/playback/container/c3BvdGlmeTpwbGF5bGlzdDozN2k5ZFFaRjFEWDBraFRZM0hGQTRN" isPresetable="false">
|
||||
<itemName>Aiyomi</itemName>
|
||||
<containerArt>https://example.invalid/cover.jpg</containerArt>
|
||||
</ContentItem>
|
||||
<track>Aiyomi</track>
|
||||
<artist>Naritomi</artist>
|
||||
<album>Aiyomi</album>
|
||||
<stationName></stationName>
|
||||
<art artImageStatus="IMAGE_PRESENT">https://example.invalid/cover.jpg</art>
|
||||
<time total="156">29</time>
|
||||
<skipEnabled/>
|
||||
<playStatus>PLAY_STATE</playStatus>
|
||||
<streamType>TRACK_ONDEMAND</streamType>
|
||||
</nowPlaying>
|
||||
@@ -42,6 +42,11 @@ type PushWiFiCredentialsParams struct {
|
||||
//
|
||||
// The speaker confirms the request before disconnecting; expect to lose
|
||||
// the AP link within ~30 seconds.
|
||||
//
|
||||
// Empirically the first POST often races the speaker's setup endpoint
|
||||
// readiness — the connection times out, then a second POST a few seconds
|
||||
// later succeeds immediately. We retry once internally so the caller
|
||||
// doesn't have to.
|
||||
func PushWiFiCredentials(ctx context.Context, p PushWiFiCredentialsParams) error {
|
||||
if p.SSID == "" {
|
||||
return fmt.Errorf("PushWiFiCredentials: SSID is required")
|
||||
@@ -69,31 +74,78 @@ func PushWiFiCredentials(ctx context.Context, p PushWiFiCredentialsParams) error
|
||||
|
||||
url := "http://" + hostPort + "/addWirelessProfile"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "text/xml")
|
||||
|
||||
httpClient := p.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 10 * time.Second}
|
||||
// No client-side timeout: let the per-attempt sub-context
|
||||
// govern. The CLI passes a context deadline (default 30 s
|
||||
// in setupWiFiPushCmd) and a hard-coded 10 s here would
|
||||
// race it for no benefit.
|
||||
httpClient = &http.Client{}
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("POST %s: %w", url, err)
|
||||
// Per-attempt cap so a stuck first attempt doesn't burn the whole
|
||||
// budget. 12 s is well above the typical sub-second response time
|
||||
// when the endpoint is healthy, and the failure mode we're working
|
||||
// around (first attempt hangs until the deadline elapses) means
|
||||
// any value here is mostly a sub-budget for a stuck attempt.
|
||||
const perAttemptTimeout = 12 * time.Second
|
||||
// Pause between attempts gives the speaker's setup endpoint a
|
||||
// moment to finish whatever initialization the first POST kicked
|
||||
// off (the empirical workaround that motivated this retry).
|
||||
const interAttemptDelay = 2 * time.Second
|
||||
|
||||
attempt := func(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "text/xml")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("POST %s: %w", url, err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("POST %s returned %d: %s", url, resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
// Two attempts: the second is silent on the wire when the first
|
||||
// already succeeded (returns at the first non-error), or carries
|
||||
// the recovery when the first failed.
|
||||
const maxAttempts = 2
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("POST %s returned %d: %s", url, resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
var lastErr error
|
||||
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
if i > 0 {
|
||||
select {
|
||||
case <-time.After(interAttemptDelay):
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("PushWiFiCredentials: %w (last attempt error: %w)", ctx.Err(), lastErr)
|
||||
}
|
||||
}
|
||||
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, perAttemptTimeout)
|
||||
err := attempt(attemptCtx)
|
||||
|
||||
cancel()
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
return nil
|
||||
return fmt.Errorf("PushWiFiCredentials: both attempts failed (last: %w)", lastErr)
|
||||
}
|
||||
|
||||
// PollConfig governs the retry cadence of WaitForAP and WaitForOnline.
|
||||
|
||||
@@ -14,12 +14,14 @@ import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed testdata/info.xml testdata/presets.xml testdata/recents.xml
|
||||
//go:embed testdata/info.xml testdata/presets.xml testdata/recents.xml testdata/networkinfo.xml testdata/sources.xml testdata/supportedurls.xml testdata/now_playing.xml
|
||||
var fixtures embed.FS
|
||||
|
||||
// Config configures a fake speaker. The zero value is valid and binds the
|
||||
@@ -34,6 +36,19 @@ type Config struct {
|
||||
// diagnostic shell. Empty disables the telnet listener entirely.
|
||||
// Use "127.0.0.1:17000" to match the real port the wizard probes.
|
||||
TelnetListen string
|
||||
|
||||
// FixtureOverrides replaces the response body for the given fixture
|
||||
// route (e.g. "/info", "/presets", "/sources") with the supplied
|
||||
// bytes. Routes not present in the map fall through to the embedded
|
||||
// defaults shipped under testdata/. A nil or empty map keeps the
|
||||
// default behaviour the screenshot pipeline relies on.
|
||||
//
|
||||
// Stateful handlers (/getGroup, /addGroup, /updateGroup,
|
||||
// /removeGroup) are not affected — overrides only apply to the
|
||||
// GET fixture routes. Use this to wire issue-specific payloads
|
||||
// into per-issue regression tests; see
|
||||
// pkg/service/setup/issue218_regression_test.go for the pattern.
|
||||
FixtureOverrides map[string][]byte
|
||||
}
|
||||
|
||||
// Server is a running fake speaker. It bundles whichever sub-servers
|
||||
@@ -43,6 +58,34 @@ type Server struct {
|
||||
srv *http.Server
|
||||
httpAddr string
|
||||
telnet *telnetServer
|
||||
|
||||
mu sync.Mutex
|
||||
notifications []NotificationCall
|
||||
}
|
||||
|
||||
// NotificationCall records a single POST /notification request the
|
||||
// fake received. Tests use it to assert that AfterTouch (or any
|
||||
// other component under test) fired the expected speaker-side
|
||||
// notification.
|
||||
type NotificationCall struct {
|
||||
// Body is the request body verbatim.
|
||||
Body []byte
|
||||
// ContentType is the value of the Content-Type header.
|
||||
ContentType string
|
||||
}
|
||||
|
||||
// Notifications returns a snapshot of every POST /notification call
|
||||
// the fake has received, in arrival order. The slice is independent
|
||||
// of the server's internal state — callers can keep it for assertions
|
||||
// without holding a lock.
|
||||
func (s *Server) Notifications() []NotificationCall {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
out := make([]NotificationCall, len(s.notifications))
|
||||
copy(out, s.notifications)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Start binds the configured listeners and serves them in background
|
||||
@@ -59,17 +102,18 @@ func Start(cfg Config) (*Server, error) {
|
||||
return nil, fmt.Errorf("fakespeaker: listen %s: %w", httpListen, err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
registerRoutes(mux)
|
||||
|
||||
s := &Server{
|
||||
srv: &http.Server{
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
},
|
||||
httpAddr: ln.Addr().String(),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
registerRoutes(mux, cfg.FixtureOverrides, s)
|
||||
|
||||
s.srv = &http.Server{
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = s.srv.Serve(ln)
|
||||
}()
|
||||
@@ -116,10 +160,70 @@ func (s *Server) Stop(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func registerRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/info", serveFixture("testdata/info.xml"))
|
||||
mux.HandleFunc("/presets", serveFixture("testdata/presets.xml"))
|
||||
mux.HandleFunc("/recents", serveFixture("testdata/recents.xml"))
|
||||
func registerRoutes(mux *http.ServeMux, overrides map[string][]byte, s *Server) {
|
||||
fixture := func(route, embedPath string) {
|
||||
mux.HandleFunc(route, serveFixtureOr(embedPath, overrides[route]))
|
||||
}
|
||||
|
||||
fixture("/info", "testdata/info.xml")
|
||||
fixture("/presets", "testdata/presets.xml")
|
||||
fixture("/recents", "testdata/recents.xml")
|
||||
fixture("/networkInfo", "testdata/networkinfo.xml")
|
||||
fixture("/sources", "testdata/sources.xml")
|
||||
fixture("/supportedURLs", "testdata/supportedurls.xml")
|
||||
fixture("/now_playing", "testdata/now_playing.xml")
|
||||
|
||||
mux.HandleFunc("/getGroup", serveEmptyGroup)
|
||||
mux.HandleFunc("/addGroup", handleAddGroup)
|
||||
mux.HandleFunc("/updateGroup", handleUpdateGroup)
|
||||
mux.HandleFunc("/removeGroup", handleRemoveGroup)
|
||||
mux.HandleFunc("/notification", s.handleNotification)
|
||||
}
|
||||
|
||||
// handleNotification records a POST /notification call so tests can
|
||||
// assert that AfterTouch fired the expected speaker-side nudge (e.g.
|
||||
// the <sourcesUpdated/> notification that recovers the source list
|
||||
// after a factory reset, per issue #234). GET returns 405 — real
|
||||
// speakers expose /notification as POST-only.
|
||||
func (s *Server) handleNotification(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(http.MaxBytesReader(w, r.Body, 64*1024))
|
||||
|
||||
s.mu.Lock()
|
||||
s.notifications = append(s.notifications, NotificationCall{
|
||||
Body: body,
|
||||
ContentType: r.Header.Get("Content-Type"),
|
||||
})
|
||||
s.mu.Unlock()
|
||||
|
||||
// Real speakers respond with <status>/notification</status>; the
|
||||
// pkg/client.Client.NotifySourcesUpdated path validates that
|
||||
// shape, so the fake has to match it too.
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?>` + "\n<status>/notification</status>\n"))
|
||||
}
|
||||
|
||||
// serveFixtureOr returns a handler that writes override (when non-nil)
|
||||
// or the embedded fixture at embedPath (when override is nil). The
|
||||
// override is snapshotted at construction so later mutations of the
|
||||
// caller's slice don't change the served body.
|
||||
func serveFixtureOr(embedPath string, override []byte) http.HandlerFunc {
|
||||
if override != nil {
|
||||
snapshot := append([]byte(nil), override...)
|
||||
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
_, _ = w.Write(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
return serveFixture(embedPath)
|
||||
}
|
||||
|
||||
func serveFixture(path string) http.HandlerFunc {
|
||||
@@ -137,3 +241,124 @@ func serveFixture(path string) http.HandlerFunc {
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
}
|
||||
|
||||
// serveEmptyGroup mirrors a real device's /getGroup response when it is
|
||||
// not part of a stereo pair: an empty <group/> element. Tests that want
|
||||
// to assert "no group" round-trip semantics can rely on this shape.
|
||||
func serveEmptyGroup(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>` + "\n<group/>\n"))
|
||||
}
|
||||
|
||||
// handleAddGroup echoes the posted <group> XML back with
|
||||
// <status>GROUP_OK</status> appended, matching the success path
|
||||
// documented for the stereo-pair flow in issue #252 (see also
|
||||
// soundtouch-cli/cmd_group.go and pkg/service/handlers/handlers_marge.go).
|
||||
// On GET, returns the same empty-group shape as /getGroup so curl
|
||||
// smoke-tests don't 405. Anything other than GET/POST gets a 405.
|
||||
func handleAddGroup(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
serveEmptyGroup(w, r)
|
||||
return
|
||||
case http.MethodPost:
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(http.MaxBytesReader(w, r.Body, 64*1024))
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
|
||||
resp := buildAddGroupResponse(body)
|
||||
_, _ = w.Write(resp)
|
||||
}
|
||||
|
||||
// handleUpdateGroup mirrors handleAddGroup's contract: POST a <group>
|
||||
// payload, get the same payload back with <status>GROUP_OK</status>
|
||||
// appended. Real speakers use this for renames (POST /updateGroup with
|
||||
// the changed <name>) and other in-place edits to an existing pair.
|
||||
// GET returns the same empty-group shape /getGroup uses; non-GET/POST
|
||||
// gets a 405.
|
||||
func handleUpdateGroup(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
serveEmptyGroup(w, r)
|
||||
return
|
||||
case http.MethodPost:
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(http.MaxBytesReader(w, r.Body, 64*1024))
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
|
||||
resp := buildAddGroupResponse(body)
|
||||
_, _ = w.Write(resp)
|
||||
}
|
||||
|
||||
// handleRemoveGroup matches the documented wiki behaviour: GET on the
|
||||
// master speaker, no body, returns the now-empty group shape. The real
|
||||
// device dissolves the pair on receipt; the fake is stateless so it
|
||||
// just always responds as "no group right now".
|
||||
func handleRemoveGroup(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
serveEmptyGroup(w, r)
|
||||
}
|
||||
|
||||
// buildAddGroupResponse inserts <status>GROUP_OK</status> before the
|
||||
// closing </group> tag of the posted body. If the body is empty or does
|
||||
// not contain </group>, it falls back to a minimal canned success
|
||||
// response so callers still see a 200 + parseable XML.
|
||||
func buildAddGroupResponse(posted []byte) []byte {
|
||||
const closeTag = "</group>"
|
||||
|
||||
const okFragment = " <status>GROUP_OK</status>\n"
|
||||
|
||||
if len(posted) == 0 {
|
||||
return []byte(`<?xml version="1.0" encoding="UTF-8"?>` + "\n<group>\n" + okFragment + closeTag + "\n")
|
||||
}
|
||||
|
||||
idx := indexOfClose(posted, closeTag)
|
||||
if idx < 0 {
|
||||
return []byte(`<?xml version="1.0" encoding="UTF-8"?>` + "\n<group>\n" + okFragment + closeTag + "\n")
|
||||
}
|
||||
|
||||
out := make([]byte, 0, len(posted)+len(okFragment))
|
||||
out = append(out, posted[:idx]...)
|
||||
out = append(out, []byte(okFragment)...)
|
||||
out = append(out, posted[idx:]...)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// indexOfClose returns the index of the last occurrence of needle in b,
|
||||
// or -1 if not present. We scan from the right because real-world
|
||||
// payloads can technically nest <group> blocks (e.g. inside <roles>),
|
||||
// even though the documented stereo-pair payload does not.
|
||||
func indexOfClose(b []byte, needle string) int {
|
||||
if len(needle) == 0 || len(b) < len(needle) {
|
||||
return -1
|
||||
}
|
||||
|
||||
for i := len(b) - len(needle); i >= 0; i-- {
|
||||
if string(b[i:i+len(needle)]) == needle {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package fakespeaker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -29,6 +31,12 @@ func TestFakeSpeakerServesFixtures(t *testing.T) {
|
||||
{"/info", "info"},
|
||||
{"/presets", "presets"},
|
||||
{"/recents", "recents"},
|
||||
{"/networkInfo", "networkInfo"},
|
||||
{"/sources", "sources"},
|
||||
{"/supportedURLs", "supportedURLs"},
|
||||
{"/getGroup", "group"},
|
||||
{"/removeGroup", "group"},
|
||||
{"/now_playing", "nowPlaying"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -62,3 +70,255 @@ func TestFakeSpeakerServesFixtures(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeSpeakerAddGroupEchoesWithGroupOK(t *testing.T) {
|
||||
s, err := Start(Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_ = s.Stop(ctx)
|
||||
})
|
||||
|
||||
posted := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<group>
|
||||
<name>TEST</name>
|
||||
<masterDeviceId>DEADBEEFCAFE</masterDeviceId>
|
||||
<roles>
|
||||
<groupRole><deviceId>DEADBEEFCAFE</deviceId><role>LEFT</role><ipAddress>127.0.0.1</ipAddress></groupRole>
|
||||
<groupRole><deviceId>0000DEADBEEF</deviceId><role>RIGHT</role><ipAddress>127.0.0.2</ipAddress></groupRole>
|
||||
</roles>
|
||||
</group>`
|
||||
|
||||
resp, err := http.Post("http://"+s.HTTPAddr()+"/addGroup", "application/xml", strings.NewReader(posted)) //nolint:noctx
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
|
||||
// Echo: the posted name + roles survive in the response.
|
||||
if !bytes.Contains(body, []byte("<name>TEST</name>")) {
|
||||
t.Errorf("response missing posted <name>; body:\n%s", body)
|
||||
}
|
||||
|
||||
if !bytes.Contains(body, []byte("<masterDeviceId>DEADBEEFCAFE</masterDeviceId>")) {
|
||||
t.Errorf("response missing posted <masterDeviceId>; body:\n%s", body)
|
||||
}
|
||||
|
||||
// Success marker: <status>GROUP_OK</status> appears before </group>.
|
||||
statusIdx := bytes.Index(body, []byte("<status>GROUP_OK</status>"))
|
||||
if statusIdx < 0 {
|
||||
t.Fatalf("response missing <status>GROUP_OK</status>; body:\n%s", body)
|
||||
}
|
||||
|
||||
closeIdx := bytes.LastIndex(body, []byte("</group>"))
|
||||
if closeIdx < 0 || statusIdx >= closeIdx {
|
||||
t.Errorf("<status> not nested inside <group>...</group>; body:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeSpeakerUpdateGroupEchoesWithGroupOK(t *testing.T) {
|
||||
s, err := Start(Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_ = s.Stop(ctx)
|
||||
})
|
||||
|
||||
posted := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<group>
|
||||
<name>RENAMED</name>
|
||||
<masterDeviceId>DEADBEEFCAFE</masterDeviceId>
|
||||
</group>`
|
||||
|
||||
resp, err := http.Post("http://"+s.HTTPAddr()+"/updateGroup", "application/xml", strings.NewReader(posted)) //nolint:noctx
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Contains(body, []byte("<name>RENAMED</name>")) {
|
||||
t.Errorf("response missing posted <name>; body:\n%s", body)
|
||||
}
|
||||
|
||||
if !bytes.Contains(body, []byte("<status>GROUP_OK</status>")) {
|
||||
t.Errorf("response missing <status>GROUP_OK</status>; body:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeSpeakerFixtureOverride_ReplacesEmbeddedBody(t *testing.T) {
|
||||
custom := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="42"><ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/sCUSTOM" isPresetable="true"><itemName>Custom Override</itemName></ContentItem></preset>
|
||||
</presets>`)
|
||||
|
||||
s, err := Start(Config{
|
||||
FixtureOverrides: map[string][]byte{
|
||||
"/presets": custom,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_ = s.Stop(ctx)
|
||||
})
|
||||
|
||||
// Overridden route returns the custom body verbatim.
|
||||
resp, err := http.Get("http://" + s.HTTPAddr() + "/presets") //nolint:noctx
|
||||
if err != nil {
|
||||
t.Fatalf("get /presets: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(body, custom) {
|
||||
t.Errorf("/presets body mismatch.\ngot:\n%s\nwant:\n%s", body, custom)
|
||||
}
|
||||
|
||||
// Non-overridden route still serves the embedded default.
|
||||
resp2, err := http.Get("http://" + s.HTTPAddr() + "/info") //nolint:noctx
|
||||
if err != nil {
|
||||
t.Fatalf("get /info: %v", err)
|
||||
}
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
|
||||
body2, err := io.ReadAll(resp2.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read /info body: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Contains(body2, []byte(`deviceID="DEADBEEFCAFE"`)) {
|
||||
t.Errorf("/info default fixture missing expected deviceID; body:\n%s", body2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeSpeakerNotificationRecorder(t *testing.T) {
|
||||
s, err := Start(Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_ = s.Stop(ctx)
|
||||
})
|
||||
|
||||
body := `<updates deviceID="DEADBEEFCAFE"><sourcesUpdated/></updates>`
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost,
|
||||
"http://"+s.HTTPAddr()+"/notification",
|
||||
strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
got := s.Notifications()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("Notifications() returned %d entries, want 1", len(got))
|
||||
}
|
||||
|
||||
// The test POSTs the request body verbatim; the recorder must
|
||||
// return it byte-identical. (Wire-shape variation — self-closing
|
||||
// vs long-form sourcesUpdated — happens upstream in
|
||||
// pkg/client.NotifySourcesUpdated, not here.)
|
||||
if string(got[0].Body) != body {
|
||||
t.Errorf("body = %q, want %q", got[0].Body, body)
|
||||
}
|
||||
|
||||
if got[0].ContentType != "application/xml" {
|
||||
t.Errorf("ContentType = %q, want application/xml", got[0].ContentType)
|
||||
}
|
||||
|
||||
// GET on the same path is a 405 — real speakers don't expose it.
|
||||
getResp, err := http.Get("http://" + s.HTTPAddr() + "/notification") //nolint:noctx
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer func() { _ = getResp.Body.Close() }()
|
||||
|
||||
if getResp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("GET status = %d, want 405", getResp.StatusCode)
|
||||
}
|
||||
|
||||
if got := getResp.Header.Get("Allow"); got != "POST" {
|
||||
t.Errorf("Allow header = %q, want POST", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeSpeakerRemoveGroupRejectsNonGET(t *testing.T) {
|
||||
s, err := Start(Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_ = s.Stop(ctx)
|
||||
})
|
||||
|
||||
resp, err := http.Post("http://"+s.HTTPAddr()+"/removeGroup", "application/xml", strings.NewReader("")) //nolint:noctx
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("status = %d, want 405", resp.StatusCode)
|
||||
}
|
||||
|
||||
if got := resp.Header.Get("Allow"); got != "GET" {
|
||||
t.Errorf("Allow header = %q, want %q", got, "GET")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<info deviceID="DEADBEEFCAFE">
|
||||
<name>Demo SoundTouch</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<margeAccountUUID>0000000</margeAccountUUID>
|
||||
<margeAccountUUID>1234567</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<networkInfo wifiProfileCount="1">
|
||||
<interfaces>
|
||||
<interface type="WIFI_INTERFACE" name="wlan0" macAddress="02:00:00:00:00:01" ipAddress="127.0.0.1" ssid="DemoNetwork" frequencyKHz="5500000" state="NETWORK_WIFI_CONNECTED" signal="EXCELLENT_SIGNAL" mode="STATION"/>
|
||||
</interfaces>
|
||||
</networkInfo>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<nowPlaying deviceID="DEADBEEFCAFE" source="STANDBY">
|
||||
<ContentItem source="STANDBY" isPresetable="false"/>
|
||||
</nowPlaying>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<sources deviceID="DEADBEEFCAFE">
|
||||
<sourceItem source="AUX" sourceAccount="AUX" status="READY" isLocal="true" multiroomallowed="true">AUX IN</sourceItem>
|
||||
<sourceItem source="BLUETOOTH" status="UNAVAILABLE" isLocal="true" multiroomallowed="true"/>
|
||||
<sourceItem source="SPOTIFY" sourceAccount="DemoSpotifyAccount" status="UNAVAILABLE" isLocal="false" multiroomallowed="true">DemoSpotifyAccount</sourceItem>
|
||||
<sourceItem source="TUNEIN" status="READY" isLocal="false" multiroomallowed="true"/>
|
||||
<sourceItem source="LOCAL_INTERNET_RADIO" status="READY" isLocal="false" multiroomallowed="true"/>
|
||||
</sources>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<supportedURLs deviceID="DEADBEEFCAFE">
|
||||
<URL location="/info"/>
|
||||
<URL location="/capabilities"/>
|
||||
<URL location="/supportedURLs"/>
|
||||
<URL location="/volume"/>
|
||||
<URL location="/presets"/>
|
||||
<URL location="/recents"/>
|
||||
<URL location="/nowPlaying"/>
|
||||
<URL location="/key"/>
|
||||
<URL location="/sources"/>
|
||||
<URL location="/serviceAvailability"/>
|
||||
<URL location="/name"/>
|
||||
<URL location="/networkInfo"/>
|
||||
<URL location="/setZone"/>
|
||||
<URL location="/addZoneSlave"/>
|
||||
<URL location="/removeZoneSlave"/>
|
||||
<URL location="/getGroup"/>
|
||||
<URL location="/addGroup"/>
|
||||
<URL location="/updateGroup"/>
|
||||
<URL location="/removeGroup"/>
|
||||
<URL location="/setMargeAccount"/>
|
||||
</supportedURLs>
|
||||
@@ -10,16 +10,27 @@ AfterTouch usually normally migrates the SoundTouch devices very noninvasive, by
|
||||
|
||||
### AfterTouch Availability
|
||||
|
||||
Some devices will expose the AfterTouch port, some won't. We currently (May 2026) suspect that the newer generation devices (those with Bluetooth) will expose the port, while the older ones won't. We're still investigating how to expose AfterTouch on all devices.
|
||||
Some devices will expose the AfterTouch port, some won't. We currently (May 2026) suspect that the newer generation devices (those with Bluetooth) will expose the port, while the older ones won't. We're still investigating how to expose AfterTouch on all devices.
|
||||
|
||||
If your device doesn't expose the port, you can still use the on-device installer, but you'll need to run AfterTouch on each one of your speakers individually and may only access AfterTouch via ssh port forwarding. This will also make OAuth authentication a little more tricky, but should also work via SSH port forwarding.
|
||||
|
||||
### Space Limitation
|
||||
|
||||
The storage space on the SoundTouch devices is very limited. At the moment only one AfterTouch installation barely fits on them with enough room for the data it needs to maintain. When installing, make sure that you have removed any binaries and folders of previous installation attempts.
|
||||
The storage space on the SoundTouch devices is very limited — stock rootfs typically has only a few MB free (e.g. ~4 MB on the ST20, see issue #268), well below the AfterTouch binary's ~12 MB. To work around this, the installer puts everything on `/mnt/nv/aftertouch` by default (the persistent partition, typically ~30 MB free) and points `/opt/aftertouch` at it via a symlink so the init script and runtime paths stay unchanged. Override the install target with `INSTALL_DIR=/some/path` if you've got room elsewhere.
|
||||
|
||||
The space limitation also means we are currently unsure on how to update the system, because two binaries are already too large. We are currently working on this - both by checking how we can make the binaries smaller, but also on how we can extend the storage space (e.g. by running AfterTouch from a USB drive).
|
||||
|
||||
### Logs
|
||||
|
||||
The daemon writes to BusyBox syslog (tagged `aftertouch`) rather than to a file. Disk usage stays bounded — the syslog ring buffer is in memory — and the same `logread` recipe used elsewhere in this project works:
|
||||
|
||||
```sh
|
||||
logread | grep aftertouch | tail -20 # recent entries
|
||||
logread -f | grep aftertouch # live tail
|
||||
```
|
||||
|
||||
If the install command reports "running but :8000 not responding" or `aftertouch status` reports the listener is down, the syslog tail is the first place to look.
|
||||
|
||||
## Installation
|
||||
|
||||
Enable SSH on your SoundTouch device using the usual "Stick with remote_services" method. Connect with the following command.
|
||||
@@ -30,16 +41,45 @@ ssh -oHostKeyAlgorithms=+ssh-rsa root@<IP_ADDRESS_OF_SPEAKER>
|
||||
|
||||
Then, run the following command to install AfterTouch on the device.
|
||||
|
||||
```bash
|
||||
```bash
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
```
|
||||
|
||||
After the installation check if you can access AfterTouch from your local device by navigating to `http://<IP_ADDRESS_OF_SPEAKER>:8000`. If you can access the AfterTouch UI, you're good to go! If not, you may need to run AfterTouch on the speaker via SSH port forwarding.
|
||||
After the installation check if you can access AfterTouch from your local device by navigating to `http://<IP_ADDRESS_OF_SPEAKER>:8000`. If you can access the AfterTouch UI, you're good to go!
|
||||
|
||||
### If `http://<IP_ADDRESS_OF_SPEAKER>:8000` fails: SSH port forwarding
|
||||
|
||||
Some firmware images only bind the AfterTouch HTTP port to loopback (see issue #196). The workaround is an SSH tunnel — your machine talks to its own local `:8000`, the SSH connection forwards to the speaker's `:8000` on loopback.
|
||||
|
||||
**Open a fresh terminal on your own machine** (Linux/macOS/Windows — NOT another shell inside the speaker's SSH session — see issue #250 for the trap that catches everyone here) and run:
|
||||
|
||||
```bash
|
||||
ssh -L 8000:localhost:8000 root@<IP_ADDRESS_OF_SPEAKER>
|
||||
ssh -oHostKeyAlgorithms=+ssh-rsa -L 8000:localhost:8000 root@<IP_ADDRESS_OF_SPEAKER>
|
||||
```
|
||||
|
||||
The `-oHostKeyAlgorithms=+ssh-rsa` flag is required: SoundTouch speakers offer only legacy SSH host-key algorithms (`ssh-rsa`, `ssh-dss`) that modern OpenSSH clients refuse by default. Without it you'll see `Unable to negotiate with <ip> port 22: no matching host key type found`.
|
||||
|
||||
Leave that terminal open while you use AfterTouch. With the tunnel up, navigate to **`http://localhost:8000`** in your browser (`localhost`, not the speaker's IP).
|
||||
|
||||
### If the tunnel is open but `http://localhost:8000` still fails
|
||||
|
||||
You should see `ERR_CONNECTION_RESET` in the browser and `channel N: open failed: connect failed: Connection refused` in the SSH terminal — that means the tunnel itself works, but the AfterTouch daemon isn't listening on the speaker. Inside the SSH session, check:
|
||||
|
||||
```bash
|
||||
netstat -tlnp 2>/dev/null | grep 8000 # is anything listening?
|
||||
ps | grep -i aftertouch # is the daemon running at all?
|
||||
logread | grep aftertouch | tail -20 # recent daemon output (panics, errors)
|
||||
```
|
||||
|
||||
If the daemon isn't running, restart it:
|
||||
|
||||
```bash
|
||||
/etc/init.d/aftertouch start
|
||||
/etc/init.d/aftertouch status
|
||||
```
|
||||
|
||||
The init script's `status` now distinguishes "running with listener up" from "PID alive but listener silently died" — if you get the latter, the syslog tail above will tell you why.
|
||||
|
||||
## Updating AfterTouch
|
||||
|
||||
To update AfterTouch, simply run the installation command again. The installer will check if there's a new version available and update it if necessary.
|
||||
@@ -50,4 +90,4 @@ Before uninstall, you might want to revert the migration, especially the changes
|
||||
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/uninstall.sh | sh
|
||||
```
|
||||
```
|
||||
|
||||
@@ -17,6 +17,7 @@ PIDFILE="/var/run/$NAME.pid"
|
||||
DATADIR="/opt/aftertouch/data"
|
||||
SCRIPTNAME="/etc/init.d/$NAME"
|
||||
USER="root"
|
||||
LOG_TAG="aftertouch"
|
||||
|
||||
|
||||
# Export PATH
|
||||
@@ -41,6 +42,17 @@ case "$1" in
|
||||
|
||||
mkdir -p "$DATADIR"
|
||||
|
||||
# Pipe stdout + stderr through `logger -t $LOG_TAG` so the
|
||||
# daemon's output lands in busybox syslog (bounded ring buffer,
|
||||
# never grows on disk). Users diagnose with:
|
||||
#
|
||||
# logread | grep aftertouch | tail -20
|
||||
# logread -f | grep aftertouch # live tail
|
||||
#
|
||||
# `exec` on the daemon replaces /bin/sh so --make-pidfile records
|
||||
# the daemon's own PID (not the shell wrapper). The `logger`
|
||||
# process sits on the read end of the pipe and exits cleanly
|
||||
# when the daemon dies and closes its end.
|
||||
start-stop-daemon --start \
|
||||
--quiet \
|
||||
--pidfile "$PIDFILE" \
|
||||
@@ -48,7 +60,7 @@ case "$1" in
|
||||
--make-pidfile \
|
||||
--chuid "$USER" \
|
||||
--startas "/bin/sh" \
|
||||
-- -c "\"$DAEMON\" --data-dir '$DATADIR' --record-interactions=false --discovery-interval=60m"
|
||||
-- -c "exec \"$DAEMON\" --data-dir '$DATADIR' --record-interactions=false --discovery-interval=60m 2>&1 | logger -t $LOG_TAG"
|
||||
|
||||
tries=0
|
||||
max_tries=60
|
||||
@@ -60,6 +72,9 @@ case "$1" in
|
||||
tries=$((tries + 1))
|
||||
done
|
||||
|
||||
echo "ERROR: daemon started but http://localhost:8000 never responded within $((max_tries * 2))s." >&2
|
||||
echo " Inspect the daemon's syslog output:" >&2
|
||||
echo " logread | grep $LOG_TAG | tail -20" >&2
|
||||
exit 1
|
||||
;;
|
||||
|
||||
@@ -86,12 +101,27 @@ case "$1" in
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
PID=$(cat "$PIDFILE")
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
echo "$NAME is running."
|
||||
# PID is alive — does it actually serve HTTP? A live process
|
||||
# with a dead listener is the symptom behind issue #250
|
||||
# (Gustour's ST30: status said running, curl said
|
||||
# connection-refused). Distinguish the two states here so
|
||||
# status isn't a false-positive.
|
||||
if curl -fsS --max-time 3 http://localhost:8000 >/dev/null 2>&1; then
|
||||
echo "$NAME is running (PID $PID, http://localhost:8000 responding)."
|
||||
exit 0
|
||||
else
|
||||
echo "$NAME PID $PID is alive but http://localhost:8000 is not responding." >&2
|
||||
echo "Recent log:" >&2
|
||||
logread 2>/dev/null | grep "$LOG_TAG" | tail -10 >&2
|
||||
exit 3
|
||||
fi
|
||||
else
|
||||
echo "$NAME is not running (PID file exists but process is dead)."
|
||||
echo "$NAME is not running (PID file exists but process is dead)." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "$NAME is not running."
|
||||
exit 3
|
||||
fi
|
||||
;;
|
||||
|
||||
@@ -102,4 +132,4 @@ case "$1" in
|
||||
esac
|
||||
|
||||
|
||||
exit 0
|
||||
exit 0
|
||||
|
||||
@@ -1,26 +1,51 @@
|
||||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
VERSION=${VERSION:-0.78.0}
|
||||
VERSION=${VERSION:-0.80.1}
|
||||
GH_REPO=${GH_REPO:-gesellix/Bose-SoundTouch}
|
||||
BINARY_URL=${BINARY_URL:-https://github.com/$GH_REPO/releases/download/v$VERSION/soundtouch-service-v$VERSION-linux-armv7}
|
||||
INIT_SCRIPT_URL=${INIT_SCRIPT_URL:-https://raw.githubusercontent.com/$GH_REPO/v$VERSION/scripts/on-device-install/aftertouch}
|
||||
UPDATE_TMP_DIR=${UPDATE_TMP_DIR:-/media/aftertouch}
|
||||
|
||||
# Default install location is /mnt/nv/aftertouch (the persistent
|
||||
# partition), not /opt/aftertouch on rootfs. Stock SoundTouch rootfs
|
||||
# has ~4 MB free on devices like the ST20 (issue #268); the
|
||||
# AfterTouch binary is ~12 MB. /mnt/nv typically has tens of MB
|
||||
# free and persists across reboots the same way /opt would.
|
||||
#
|
||||
# /opt/aftertouch becomes a symlink into the install target so the
|
||||
# init script's hardcoded DAEMON path keeps working unchanged.
|
||||
#
|
||||
# Power users can override with INSTALL_DIR=/some/other/path.
|
||||
INSTALL_DIR=${INSTALL_DIR:-/mnt/nv/aftertouch}
|
||||
|
||||
# Scratch directory for the download. /media is tmpfs on most
|
||||
# SoundTouch firmware, fine for transient files but unrelated to
|
||||
# the persistent install target.
|
||||
UPDATE_TMP_DIR=${UPDATE_TMP_DIR:-/media/aftertouch}
|
||||
|
||||
rm -rf "$UPDATE_TMP_DIR" || true
|
||||
mkdir -p "$UPDATE_TMP_DIR"
|
||||
|
||||
echo "Installing Aftertouch $VERSION ..."
|
||||
mkdir -p /opt/aftertouch
|
||||
echo "Installing AfterTouch $VERSION to $INSTALL_DIR ..."
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# Wire /opt/aftertouch -> $INSTALL_DIR so the init script
|
||||
# (DAEMON=/opt/aftertouch/aftertouch-service) finds the binary
|
||||
# regardless of which target we picked. Replace any prior
|
||||
# /opt/aftertouch (directory or stale symlink) before re-creating.
|
||||
if [ "$INSTALL_DIR" != "/opt/aftertouch" ]; then
|
||||
rm -rf /opt/aftertouch
|
||||
ln -sf "$INSTALL_DIR" /opt/aftertouch
|
||||
fi
|
||||
|
||||
curl \
|
||||
-sSL \
|
||||
-o "$UPDATE_TMP_DIR/binary" \
|
||||
--fail \
|
||||
"$BINARY_URL"
|
||||
|
||||
mv "$UPDATE_TMP_DIR/binary" /opt/aftertouch/aftertouch-service
|
||||
chmod +x /opt/aftertouch/aftertouch-service
|
||||
mv "$UPDATE_TMP_DIR/binary" "$INSTALL_DIR/aftertouch-service"
|
||||
chmod +x "$INSTALL_DIR/aftertouch-service"
|
||||
|
||||
echo "Creating init script..."
|
||||
curl \
|
||||
@@ -33,12 +58,33 @@ mv "$UPDATE_TMP_DIR/init-script" /etc/init.d/aftertouch
|
||||
chmod +x /etc/init.d/aftertouch
|
||||
update-rc.d aftertouch defaults
|
||||
|
||||
echo "Installation complete. Running initial startup to accelerate future startups..."
|
||||
echo "Installation complete. Running initial startup..."
|
||||
/etc/init.d/aftertouch start
|
||||
|
||||
/etc/init.d/aftertouch status
|
||||
|
||||
echo "Installation complete. Aftertouch $VERSION is now running on your device."
|
||||
echo "You can try to connect to at http://<your-device-ip>:8000 ."
|
||||
echo "If the connection fails, reconnect ssh with port forwarding like:"
|
||||
echo "ssh -L 8000:localhost:8000 root@<IP_ADDRESS_OF_SPEAKER>"
|
||||
# Post-install verification: the init script's own poll loop only
|
||||
# checks that the daemon registered a PID file; that's not enough
|
||||
# evidence the listener is actually serving HTTP. Issue #250 shipped
|
||||
# with a "running but unreachable" state where status was green and
|
||||
# `curl :8000` got connection-refused. Re-check directly here and
|
||||
# surface the recent syslog if it fails — the init script pipes the
|
||||
# daemon's stdout/stderr through `logger -t aftertouch`, so panics
|
||||
# land in busybox syslog and `logread` reads them out.
|
||||
if curl -fsS --max-time 10 http://localhost:8000 >/dev/null 2>&1; then
|
||||
echo "Installation complete. AfterTouch $VERSION is now running on your device."
|
||||
echo "Connect to http://<your-device-ip>:8000 from another machine on the LAN."
|
||||
echo "If the device doesn't expose :8000 directly, port-forward via SSH:"
|
||||
echo " ssh -L 8000:localhost:8000 root@<IP_ADDRESS_OF_SPEAKER>"
|
||||
else
|
||||
echo "WARNING: the init script reports AfterTouch as running, but" >&2
|
||||
echo " http://localhost:8000 isn't responding. The daemon may have" >&2
|
||||
echo " panicked shortly after start. Recent aftertouch syslog:" >&2
|
||||
echo "" >&2
|
||||
logread 2>/dev/null | grep aftertouch | tail -20 >&2 || \
|
||||
echo " (logread returned nothing for tag 'aftertouch'; the daemon" >&2
|
||||
echo "" >&2
|
||||
echo " For a live view of the daemon's output, run:" >&2
|
||||
echo " logread -f | grep aftertouch" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
/etc/init.d/aftertouch stop
|
||||
rm -rf /etc/init.d/aftertouch
|
||||
#!/bin/sh
|
||||
# Uninstall AfterTouch on-device. Handles both the historical
|
||||
# layout (/opt/aftertouch as a directory) and the post-#268 layout
|
||||
# (/opt/aftertouch as a symlink into /mnt/nv/aftertouch).
|
||||
set -eu
|
||||
|
||||
/etc/init.d/aftertouch stop || true
|
||||
rm -f /etc/init.d/aftertouch
|
||||
update-rc.d -f aftertouch remove
|
||||
rm -rf /opt/aftertouch
|
||||
|
||||
# If /opt/aftertouch is a symlink, resolve it and remove the target
|
||||
# before unlinking, so we don't leave ~12 MB of orphan binary on
|
||||
# /mnt/nv. Tolerate either layout — readlink -f returns the same
|
||||
# path for a real directory, and rm -rf on a missing path with
|
||||
# set -eu would abort.
|
||||
target="$(readlink -f /opt/aftertouch 2>/dev/null || echo /opt/aftertouch)"
|
||||
if [ -e "$target" ]; then
|
||||
rm -rf "$target"
|
||||
fi
|
||||
if [ -L /opt/aftertouch ] || [ -e /opt/aftertouch ]; then
|
||||
rm -rf /opt/aftertouch
|
||||
fi
|
||||
|
||||
@@ -28,7 +28,7 @@ You can override defaults:
|
||||
|
||||
```bash
|
||||
sudo \
|
||||
VERSION=v0.78.0 \
|
||||
VERSION=v0.80.1 \
|
||||
HOSTNAME_FQDN=soundtouch.local \
|
||||
HTTP_PORT=80 \
|
||||
HTTPS_PORT=443 \
|
||||
|
||||
@@ -10,7 +10,7 @@ set -euo pipefail
|
||||
# Examples (override defaults via env vars):
|
||||
#
|
||||
# sudo \
|
||||
# VERSION=v0.17.0 \
|
||||
# VERSION=v0.80.0 \
|
||||
# HOSTNAME_FQDN=soundtouch.local \
|
||||
# HTTP_PORT=80 \
|
||||
# HTTPS_PORT=443 \
|
||||
@@ -18,7 +18,7 @@ set -euo pipefail
|
||||
# bash install.sh
|
||||
#
|
||||
# Or with a version argument to perform an update:
|
||||
# sudo bash install.sh v0.18.1
|
||||
# sudo bash install.sh v0.80.1
|
||||
#
|
||||
# Notes:
|
||||
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
|
||||
@@ -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.78.0}}"
|
||||
VERSION="${1:-${VERSION:-v0.80.1}}"
|
||||
# Normalize version prefix
|
||||
if [[ ! "$VERSION" =~ ^v ]]; then
|
||||
VERSION="v${VERSION}"
|
||||
@@ -117,7 +117,7 @@ detect_arch_asset() {
|
||||
download_url_for() {
|
||||
local asset="$1"
|
||||
# Release asset pattern used by you earlier:
|
||||
# soundtouch-service-v0.17.0-linux-armv7
|
||||
# soundtouch-service-v0.80.1-linux-armv7
|
||||
echo "https://github.com/gesellix/Bose-SoundTouch/releases/download/${VERSION}/soundtouch-service-${VERSION}-${asset}"
|
||||
}
|
||||
|
||||
|
||||
@@ -5,21 +5,21 @@
|
||||
"path": "/",
|
||||
"click_selector": "button[onclick*=\"tab-settings\"]",
|
||||
"wait_selector": "#tab-settings.active",
|
||||
"settle_ms": 300
|
||||
"settle_ms": 2000
|
||||
},
|
||||
{
|
||||
"name": "ui-devices",
|
||||
"path": "/",
|
||||
"click_selector": "button[onclick*=\"tab-devices\"]",
|
||||
"wait_selector": "#tab-devices.active",
|
||||
"settle_ms": 500
|
||||
"settle_ms": 2500
|
||||
},
|
||||
{
|
||||
"name": "ui-sync",
|
||||
"path": "/",
|
||||
"click_selector": "button[onclick*=\"tab-sync\"]",
|
||||
"wait_selector": "#tab-sync.active",
|
||||
"settle_ms": 300
|
||||
"settle_ms": 1000
|
||||
},
|
||||
{
|
||||
"name": "ui-migration",
|
||||
@@ -31,4 +31,4 @@
|
||||
"settle_ms": 4000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -39,11 +39,18 @@ go build -o "$LOG_DIR/soundtouch-service" ./cmd/soundtouch-service
|
||||
go build -o "$LOG_DIR/dummy-speaker" ./cmd/dummy-speaker
|
||||
go build -o "$LOG_DIR/screenshots" ./scripts/screenshots
|
||||
|
||||
echo "==> seeding settings.json (generic hostname + discovery off to avoid leaking real network info)"
|
||||
echo "==> seeding settings.json (aftertouch.localhost + discovery off to avoid leaking real network info)"
|
||||
# `aftertouch.localhost` is RFC 6761: any *.localhost name resolves to
|
||||
# loopback via the system resolver in milliseconds (verified ~8ms on
|
||||
# macOS / glibc / systemd-resolved). That gives us a brand-friendly URL
|
||||
# in the screenshots without the ~5s DNS-timeout cliff that bites on
|
||||
# unresolvable hostnames like aftertouch.local — that cliff compounds
|
||||
# across /setup/settings + /setup/summary and pushes past the chromedp
|
||||
# 30s per-shot budget.
|
||||
cat > "$DATA_DIR/settings.json" <<'EOF'
|
||||
{
|
||||
"server_url": "http://aftertouch.local:8000",
|
||||
"https_server_url": "https://aftertouch.local:8443",
|
||||
"server_url": "http://aftertouch.localhost:8000",
|
||||
"https_server_url": "https://aftertouch.localhost:8443",
|
||||
"discovery_enabled": false,
|
||||
"discovery_interval": "1h"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
2026*/
|
||||
data/
|
||||
integration/testdata/
|
||||
integration/testdata*/
|
||||
|
||||
@@ -18,14 +18,20 @@ Authorization: Bearer dummy-token
|
||||
client.assert(sources.nodeName === "sources", "Root element is not 'sources'");
|
||||
|
||||
const sourceList = sources.getElementsByTagName("source");
|
||||
client.assert(sourceList.length >= 4, "Expected at least 4 source elements, found " + sourceList.length);
|
||||
client.assert(sourceList.length >= 3, "Expected at least 3 source elements, found " + sourceList.length);
|
||||
|
||||
const expectedIds = ["10001", "10002", "10003", "10004"];
|
||||
// AUX (id=10001, sourceproviderid=9) is intentionally excluded from
|
||||
// cloud responses — real Bose never emitted AUX in /full or /sources;
|
||||
// the speaker enumerates AUX from its own hardware via isLocal=true
|
||||
// in :8090/sources. See pkg/service/marge/marge.go getAccountSources
|
||||
// and commit 2b40481 (#195/#269).
|
||||
const expectedIds = ["10002", "10003", "10004"];
|
||||
for (let i = 0; i < sourceList.length; i++) {
|
||||
const source = sourceList.item(i);
|
||||
const sourceId = source.getAttribute("id");
|
||||
client.assert(sourceId !== "10001", "Cloud /sources must not include AUX (id=10001)");
|
||||
if (i < expectedIds.length) {
|
||||
client.assert(sourceId === expectedIds[i], "Wrong source ID at index " + i);
|
||||
client.assert(sourceId === expectedIds[i], "Wrong source ID at index " + i + ": got " + sourceId + ", want " + expectedIds[i]);
|
||||
}
|
||||
client.assert(source.getAttribute("type") === "Audio", "Wrong source type for source " + sourceId);
|
||||
|
||||
|
||||
@@ -47,6 +47,18 @@ Authorization: Bearer {{token}}
|
||||
client.assert(sources !== null, "Missing 'sources' element");
|
||||
var sourceCount = sources.getElementsByTagName("source").length;
|
||||
client.assert(sourceCount > 0, "No 'source' elements found in account sources");
|
||||
client.assert(sourceCount === 6, "Expected 6 sources (AUX, INTERNET_RADIO, LOCAL_INTERNET_RADIO, TUNEIN, RADIO_BROWSER, Spotify) but got " + sourceCount);
|
||||
// AUX (sourceproviderid=9) is intentionally excluded from cloud-side
|
||||
// /full responses — real Bose never emitted it; the speaker enumerates
|
||||
// AUX from its own hardware via isLocal=true in :8090/sources. See
|
||||
// pkg/service/marge/marge.go getAccountSources for the reasoning and
|
||||
// commit 2b40481 for the fix that closed #195/#269.
|
||||
client.assert(sourceCount === 5, "Expected 5 cloud sources (INTERNET_RADIO, LOCAL_INTERNET_RADIO, TUNEIN, RADIO_BROWSER, Spotify; AUX is hardware-local) but got " + sourceCount);
|
||||
|
||||
// Explicit negative assertion: AUX must not appear in /full.
|
||||
var sourceList = sources.getElementsByTagName("source");
|
||||
for (var i = 0; i < sourceList.length; i++) {
|
||||
var pid = sourceList[i].getElementsByTagName("sourceproviderid")[0];
|
||||
client.assert(!pid || pid.textContent !== "9", "/full must not include AUX (sourceproviderid=9)");
|
||||
}
|
||||
});
|
||||
%}
|
||||
|
||||
@@ -33,6 +33,10 @@ Authorization: Bearer {{token}}
|
||||
|
||||
const ipaddress = device.getElementsByTagName("ipaddress")[0];
|
||||
client.assert(ipaddress !== undefined, "Response body should contain <ipaddress>");
|
||||
|
||||
// Capture the initial createdOn so rename_device.http can
|
||||
// assert it survives a PUT later in the test sequence.
|
||||
client.global.set("initialCreatedOn", createdOn.textContent);
|
||||
});
|
||||
%}
|
||||
|
||||
@@ -61,6 +65,13 @@ Authorization: Bearer {{token}}
|
||||
const createdOn = device.getElementsByTagName("createdOn")[0];
|
||||
client.assert(createdOn !== undefined, "Response body should contain <createdOn>");
|
||||
client.assert(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|(\+\d{2}:\d{2}))$/.test(createdOn.textContent), "createdOn should be a valid ISO8601 timestamp");
|
||||
// This variant POST re-uses the same deviceId as the first
|
||||
// register above, so it's an upsert — issue #285 made the
|
||||
// datastore preserve the original createdOn unconditionally.
|
||||
// The captured global is the load-bearing assertion: if it
|
||||
// ever differs here, CreatedOn preservation regressed.
|
||||
client.assert(createdOn.textContent === client.global.get("initialCreatedOn"),
|
||||
"createdOn (" + createdOn.textContent + ") differs from the value captured by the first POST (" + client.global.get("initialCreatedOn") + ") — upsert must preserve the original timestamp");
|
||||
|
||||
const name = device.getElementsByTagName("name")[0];
|
||||
client.assert(name !== undefined, "Response body should contain <name>");
|
||||
@@ -68,7 +79,7 @@ Authorization: Bearer {{token}}
|
||||
|
||||
const updatedOn = device.getElementsByTagName("updatedOn")[0];
|
||||
client.assert(updatedOn !== undefined, "Response body should contain <updatedOn>");
|
||||
client.assert(updatedOn.textContent === createdOn.textContent, "updatedOn should match createdOn for a new device");
|
||||
client.assert(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|(\+\d{2}:\d{2}))$/.test(updatedOn.textContent), "updatedOn should be a valid ISO8601 timestamp");
|
||||
|
||||
const ipaddress = device.getElementsByTagName("ipaddress")[0];
|
||||
client.assert(ipaddress !== undefined, "Response body should contain <ipaddress>");
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
### PUT /streaming/account/{accountId}/device/{deviceId} (Rename Device)
|
||||
###
|
||||
### Speakers fire this against AfterTouch when the user renames them
|
||||
### via the Bose App or `soundtouch-cli name set`. Before issue #285's
|
||||
### route fix the request fell through to the [UNHANDLED] catch-all
|
||||
### and proxied to streaming.bose.com (401), so the speaker retried
|
||||
### in a loop and the Bose App showed the rename spinning forever.
|
||||
### This test runs after register_device.http (which creates the
|
||||
### initial record) and after power_on.http (which seeds the IP at
|
||||
### 192.168.1.100). The rename PUT must:
|
||||
###
|
||||
### - return 200 OK
|
||||
### - echo the new name
|
||||
### - preserve <createdOn> from the initial registration
|
||||
### - preserve <ipaddress> from the power_on update (request body
|
||||
### doesn't carry one; the datastore merge keeps the existing)
|
||||
### - refresh <updatedOn> to "now"
|
||||
PUT {{host}}/streaming/account/{{accountId}}/device/{{deviceId}}
|
||||
Content-Type: application/vnd.bose.streaming-v1.2+xml
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<device deviceid="{{deviceId}}">
|
||||
<name>{{deviceName}} (renamed)</name>
|
||||
<macaddress>{{macAddress1}}</macaddress>
|
||||
</device>
|
||||
|
||||
> {%
|
||||
client.test("Rename returned 200 OK", function() {
|
||||
client.assert(response.status === 200, "Response status should be 200, got " + response.status);
|
||||
client.assert(response.contentType.mimeType === "application/vnd.bose.streaming-v1.2+xml", "Response Content-Type should be application/vnd.bose.streaming-v1.2+xml");
|
||||
});
|
||||
|
||||
client.test("Response carries the new name and matches the deviceId", function() {
|
||||
const doc = response.body;
|
||||
const device = doc.getElementsByTagName("device")[0];
|
||||
client.assert(device !== undefined, "Response body should contain <device>");
|
||||
client.assert(device.getAttribute("deviceid") === client.variables.environment.get("deviceId"), "Response deviceid should match");
|
||||
|
||||
const name = device.getElementsByTagName("name")[0];
|
||||
client.assert(name !== undefined, "Response body should contain <name>");
|
||||
client.assert(name.textContent === client.variables.environment.get("deviceName") + " (renamed)", "Response should carry the renamed value");
|
||||
});
|
||||
|
||||
client.test("createdOn preserved across rename, updatedOn refreshed", function() {
|
||||
const device = response.body.getElementsByTagName("device")[0];
|
||||
|
||||
const createdOn = device.getElementsByTagName("createdOn")[0];
|
||||
client.assert(createdOn !== undefined, "Response body should contain <createdOn>");
|
||||
client.assert(createdOn.textContent.length > 0, "createdOn should not be empty (it must survive the rename, not be reset)");
|
||||
client.assert(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|(\+\d{2}:\d{2}))$/.test(createdOn.textContent), "createdOn should be ISO8601");
|
||||
|
||||
// The load-bearing assertion: the value must match what
|
||||
// register_device.http captured on the initial POST. If it
|
||||
// doesn't, the rename PUT regressed the "first-paired in
|
||||
// 2017" semantics — the exact bug behind the parity capture
|
||||
// at data/parity_mismatches/1771797308__streaming_account_3230304_device_A81B6A536A98.json.
|
||||
const initial = client.global.get("initialCreatedOn");
|
||||
client.assert(initial !== undefined && initial.length > 0,
|
||||
"initialCreatedOn was not captured by register_device.http — test ordering issue");
|
||||
client.assert(createdOn.textContent === initial,
|
||||
"createdOn (" + createdOn.textContent + ") differs from the value captured at registration (" + initial + ") — the rename PUT must preserve the original timestamp");
|
||||
|
||||
const updatedOn = device.getElementsByTagName("updatedOn")[0];
|
||||
client.assert(updatedOn !== undefined, "Response body should contain <updatedOn>");
|
||||
client.assert(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|(\+\d{2}:\d{2}))$/.test(updatedOn.textContent), "updatedOn should be ISO8601");
|
||||
});
|
||||
|
||||
client.test("ipaddress preserved from earlier power_on payload", function() {
|
||||
const device = response.body.getElementsByTagName("device")[0];
|
||||
const ipaddress = device.getElementsByTagName("ipaddress")[0];
|
||||
client.assert(ipaddress !== undefined, "Response body should contain <ipaddress>");
|
||||
client.assert(ipaddress.textContent === client.variables.environment.get("deviceIp"),
|
||||
"ipaddress should be preserved from the prior power_on (" + client.variables.environment.get("deviceIp") + "), not overwritten — got " + ipaddress.textContent);
|
||||
});
|
||||
%}
|
||||
|
||||
### PUT with mismatched deviceid in body (safety check)
|
||||
###
|
||||
### If the body's deviceid attribute doesn't match the URL's {device}
|
||||
### segment the handler refuses with 400 rather than silently re-key
|
||||
### the persisted record under the wrong account/device. Pins the
|
||||
### second TestIssue285_* unit-test assertion at the integration
|
||||
### layer.
|
||||
PUT {{host}}/streaming/account/{{accountId}}/device/{{deviceId}}
|
||||
Content-Type: application/vnd.bose.streaming-v1.2+xml
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<device deviceid="DEADBEEFCAFE">
|
||||
<name>Rogue Rename</name>
|
||||
<macaddress>DEADBEEFCAFE</macaddress>
|
||||
</device>
|
||||
|
||||
> {%
|
||||
client.test("Mismatched body deviceid returns 400", function() {
|
||||
client.assert(response.status === 400, "Response status should be 400 for body/url deviceid mismatch, got " + response.status);
|
||||
});
|
||||
%}
|
||||