From 382c68d2b694870405f289e2714836566426d2c0 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 31 May 2026 21:44:45 +0200 Subject: [PATCH] fix(setup): seed audionotification host(s) into /etc/hosts for /speaker TTS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit soundcork#104 confirms speakers validate the /speaker audio-notification app_key against audionotification.api.bosecm.com (100 calls/day on real Bose). Our /v1/auth shim accepts it, but a host-seeded migration only worked if the speaker resolved that host to us. DNS interception already covers it (bosecm.com substring), but the /etc/hosts migration domain list did not — so the speaker method would fail on hosts-based setups. Seed both audionotification.api.bosecm.com and the dev variant (audionotificationdev.api.bosecm.com; firmware may use either) into the migration /etc/hosts lists, and update the mock fixtures/docs accordingly. /v1/auth is path-based, so it already answers regardless of which host the speaker thinks it is calling. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/docs/reference/SPEAKER-ENDPOINT.md | 12 +++++++++--- pkg/service/handlers/handlers_setup_test.go | 2 +- pkg/service/setup/setup.go | 14 ++++++++++++++ pkg/service/setup/setup_test.go | 6 +++--- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/docs/content/docs/reference/SPEAKER-ENDPOINT.md b/docs/content/docs/reference/SPEAKER-ENDPOINT.md index 3a7d977..8edd7c0 100644 --- a/docs/content/docs/reference/SPEAKER-ENDPOINT.md +++ b/docs/content/docs/reference/SPEAKER-ENDPOINT.md @@ -339,9 +339,15 @@ box. soundtouch-web proxies it to the service, so start it with `--service-url` ### Notes and limitations -- **The `app_key` still applies.** Cloud TTS does not bypass the `/speaker` - requirement; without a working `app_key` the speaker will reject playback. -- **Model support** is the same as the direct path (primarily ST-10 Series III). +- **`app_key` validation is handled automatically (speaker method).** The + speaker validates the key by calling `GET /v1/auth` on Bose's audio + notification host (`audionotification.api.bosecm.com`, and a `…dev…` variant), + which AfterTouch intercepts (DNS substring match on `bosecm.com`, plus + `/etc/hosts` seeding during migration) and answers `200`. So any non-empty + `app_key` works; you don't need a real Bose-issued key. The `radio` method + needs no `app_key` at all. +- **Model support** for the `speaker` method is the same as the direct `/speaker` + path (primarily ST-10 Series III). Use `--method radio` on models without it. - **Reachability:** the speaker must be able to reach the service's `/media/tts/{id}` URL. The service builds it from its configured `server-url`. - Synthesized clips are cached in memory for a short time and identical requests diff --git a/pkg/service/handlers/handlers_setup_test.go b/pkg/service/handlers/handlers_setup_test.go index c40e983..f9ecf18 100644 --- a/pkg/service/handlers/handlers_setup_test.go +++ b/pkg/service/handlers/handlers_setup_test.go @@ -412,7 +412,7 @@ func (m *mockSSH) Run(command string) (string, error) { m.runCount++ if m.runCount > 1 { // Return updated hosts for verification - return "127.0.0.1 localhost\n192.0.2.100\tstreaming.bose.com\n192.0.2.100\tupdates.bose.com\n192.0.2.100\tstats.bose.com\n192.0.2.100\tbmx.bose.com\n192.0.2.100\tcontent.api.bose.io\n192.0.2.100\tevents.api.bosecm.com\n192.0.2.100\tbose-prod.apigee.net\n192.0.2.100\tworldwide.bose.com\n192.0.2.100\tmedia.bose.io\n192.0.2.100\tdownloads.bose.com\n192.0.2.100\tvoice.api.bose.io", nil + return "127.0.0.1 localhost\n192.0.2.100\tstreaming.bose.com\n192.0.2.100\tupdates.bose.com\n192.0.2.100\tstats.bose.com\n192.0.2.100\tbmx.bose.com\n192.0.2.100\tcontent.api.bose.io\n192.0.2.100\tevents.api.bosecm.com\n192.0.2.100\taudionotification.api.bosecm.com\n192.0.2.100\taudionotificationdev.api.bosecm.com\n192.0.2.100\tbose-prod.apigee.net\n192.0.2.100\tworldwide.bose.com\n192.0.2.100\tmedia.bose.io\n192.0.2.100\tdownloads.bose.com\n192.0.2.100\tvoice.api.bose.io", nil } return "127.0.0.1 localhost", nil } diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index 83424e5..bb6e51e 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -426,6 +426,13 @@ func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, _, tar "bmx.bose.com", "content.api.bose.io", "events.api.bosecm.com", + // app_key validation for /speaker audio notifications (TTS). Without + // these redirects the speaker validates against the dead Bose cloud and + // reports an invalid app key. Both the prod and dev hosts are seeded + // (firmware may use either). DNS interception already covers them via + // the bosecm.com substring; seed here for /etc/hosts migrations too. + "audionotification.api.bosecm.com", + "audionotificationdev.api.bosecm.com", "bose-prod.apigee.net", "worldwide.bose.com", "music.api.bose.com", @@ -1424,6 +1431,13 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) { "bmx.bose.com", "content.api.bose.io", "events.api.bosecm.com", + // app_key validation for /speaker audio notifications (TTS). Without + // these redirects the speaker validates against the dead Bose cloud and + // reports an invalid app key. Both the prod and dev hosts are seeded + // (firmware may use either). DNS interception already covers them via + // the bosecm.com substring; seed here for /etc/hosts migrations too. + "audionotification.api.bosecm.com", + "audionotificationdev.api.bosecm.com", "bose-prod.apigee.net", "worldwide.bose.com", "media.bose.io", diff --git a/pkg/service/setup/setup_test.go b/pkg/service/setup/setup_test.go index 054de86..7a1a186 100644 --- a/pkg/service/setup/setup_test.go +++ b/pkg/service/setup/setup_test.go @@ -154,7 +154,7 @@ func TestMigrateViaHosts(t *testing.T) { if command == "cat /etc/hosts" { // Handle both initial read and verification read if len(runCalls) > 2 { // Rough heuristic: verification happens after upload - return "192.0.2.100\tstreaming.bose.com\n192.0.2.100\tupdates.bose.com\n192.0.2.100\tstats.bose.com\n192.0.2.100\tbmx.bose.com\n192.0.2.100\tcontent.api.bose.io\n192.0.2.100\tevents.api.bosecm.com\n192.0.2.100\tbose-prod.apigee.net\n192.0.2.100\tworldwide.bose.com\n192.0.2.100\tmedia.bose.io\n192.0.2.100\tdownloads.bose.com\n192.0.2.100\tvoice.api.bose.io", nil + return "192.0.2.100\tstreaming.bose.com\n192.0.2.100\tupdates.bose.com\n192.0.2.100\tstats.bose.com\n192.0.2.100\tbmx.bose.com\n192.0.2.100\tcontent.api.bose.io\n192.0.2.100\tevents.api.bosecm.com\n192.0.2.100\taudionotification.api.bosecm.com\n192.0.2.100\taudionotificationdev.api.bosecm.com\n192.0.2.100\tbose-prod.apigee.net\n192.0.2.100\tworldwide.bose.com\n192.0.2.100\tmedia.bose.io\n192.0.2.100\tdownloads.bose.com\n192.0.2.100\tvoice.api.bose.io", nil } return "127.0.0.1 localhost", nil } @@ -232,7 +232,7 @@ func TestMigrateViaHosts_UpdateExisting(t *testing.T) { runCount++ if command == "cat /etc/hosts" { if runCount > 1 { - return "127.0.0.1 localhost\n192.0.2.100\tstreaming.bose.com\n192.0.2.100\tupdates.bose.com\n192.0.2.100\tstats.bose.com\n192.0.2.100\tbmx.bose.com\n192.0.2.100\tcontent.api.bose.io\n192.0.2.100\tevents.api.bosecm.com\n192.0.2.100\tbose-prod.apigee.net\n192.0.2.100\tworldwide.bose.com\n192.0.2.100\tmedia.bose.io\n192.0.2.100\tdownloads.bose.com\n192.0.2.100\tvoice.api.bose.io", nil + return "127.0.0.1 localhost\n192.0.2.100\tstreaming.bose.com\n192.0.2.100\tupdates.bose.com\n192.0.2.100\tstats.bose.com\n192.0.2.100\tbmx.bose.com\n192.0.2.100\tcontent.api.bose.io\n192.0.2.100\tevents.api.bosecm.com\n192.0.2.100\taudionotification.api.bosecm.com\n192.0.2.100\taudionotificationdev.api.bosecm.com\n192.0.2.100\tbose-prod.apigee.net\n192.0.2.100\tworldwide.bose.com\n192.0.2.100\tmedia.bose.io\n192.0.2.100\tdownloads.bose.com\n192.0.2.100\tvoice.api.bose.io", nil } return "127.0.0.1 localhost\n1.2.3.4\tstreaming.bose.com\n1.2.3.4\tupdates.bose.com", nil } @@ -771,7 +771,7 @@ func TestMigrateViaHosts_SkipCAIfTrusted(t *testing.T) { if command == "cat /etc/hosts" { // Handle both initial read and verification read if len(runCalls) > 2 { // Rough heuristic: verification happens after upload - return "192.0.2.100\tstreaming.bose.com\n192.0.2.100\tupdates.bose.com\n192.0.2.100\tstats.bose.com\n192.0.2.100\tbmx.bose.com\n192.0.2.100\tcontent.api.bose.io\n192.0.2.100\tevents.api.bosecm.com\n192.0.2.100\tbose-prod.apigee.net\n192.0.2.100\tworldwide.bose.com\n192.0.2.100\tmedia.bose.io\n192.0.2.100\tdownloads.bose.com\n192.0.2.100\tvoice.api.bose.io", nil + return "192.0.2.100\tstreaming.bose.com\n192.0.2.100\tupdates.bose.com\n192.0.2.100\tstats.bose.com\n192.0.2.100\tbmx.bose.com\n192.0.2.100\tcontent.api.bose.io\n192.0.2.100\tevents.api.bosecm.com\n192.0.2.100\taudionotification.api.bosecm.com\n192.0.2.100\taudionotificationdev.api.bosecm.com\n192.0.2.100\tbose-prod.apigee.net\n192.0.2.100\tworldwide.bose.com\n192.0.2.100\tmedia.bose.io\n192.0.2.100\tdownloads.bose.com\n192.0.2.100\tvoice.api.bose.io", nil } return "127.0.0.1 localhost", nil }