diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index d45e093..9537d8e 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -851,6 +851,7 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Get("/bmx-icons/*", server.HandleBmxIcons()) r.Get("/ced/*", server.HandleCedStatic()) r.Get("/web/*", server.HandleWeb()) + r.Post("/alexa/certificate", server.HandleAlexaCertificate) r.Get("/docs/*", server.HandleDocs) r.Route("/bmx", func(r chi.Router) { diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index 1f8b116..a2ca267 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -94,6 +94,7 @@ POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.( POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm 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 diff --git a/pkg/service/handlers/handlers_alexa.go b/pkg/service/handlers/handlers_alexa.go new file mode 100644 index 0000000..48c0b97 --- /dev/null +++ b/pkg/service/handlers/handlers_alexa.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" +) + +// HandleAlexaCertificate handles POST /alexa/certificate. +// +// The speaker sends a CSR (PEM, URL-form-encoded as "csr") and a JSON "data" field +// containing a Bearer token, device MAC address, device type, and AWS region. +// The real voice.api.bose.io endpoint forwards the CSR to AWS IoT, which signs it +// and returns a device certificate, the account's IoT endpoint URL, and a client ID. +// The speaker uses these to establish a persistent MQTT connection to Alexa IoT. +// +// Full implementation requires an AWS IoT integration: +// - Parse the CSR from the form body +// - Exchange it via the AWS IoT CreateKeysAndCertificate or RegisterThing API +// - Return {"certificatePem": "...", "iot_endpoint": "...", "client_id": "..."} +// +// Until implemented, Alexa voice control will not work after cloud shutdown. +func (s *Server) HandleAlexaCertificate(w http.ResponseWriter, r *http.Request) { + device := "" + + if err := r.ParseForm(); err == nil { + if data := r.FormValue("data"); data != "" { + var d struct { + Device string `json:"device"` + } + if err := json.Unmarshal([]byte(data), &d); err == nil { + device = d.Device + } + } + } + + log.Printf("[alexa] certificate provisioning not implemented (device=%s); Alexa voice control requires AWS IoT integration", device) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotImplemented) + _, _ = w.Write([]byte(`{"error":"not_implemented","message":"Alexa IoT certificate provisioning requires AWS IoT integration. See voice.api.bose.io /alexa/certificate handler."}`)) +} diff --git a/pkg/service/handlers/handlers_setup_test.go b/pkg/service/handlers/handlers_setup_test.go index b7cc1d0..8b713c5 100644 --- a/pkg/service/handlers/handlers_setup_test.go +++ b/pkg/service/handlers/handlers_setup_test.go @@ -417,7 +417,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.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com\n192.168.1.100\tmedia.bose.io\n192.168.1.100\tdownloads.bose.com", nil + return "127.0.0.1 localhost\n192.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com\n192.168.1.100\tmedia.bose.io\n192.168.1.100\tdownloads.bose.com\n192.168.1.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 9282c6e..c713346 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -280,6 +280,7 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti "music.api.bose.com", "media.bose.io", "downloads.bose.com", + "voice.api.bose.io", } var hostsLines []string @@ -1065,6 +1066,7 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) { "worldwide.bose.com", "media.bose.io", "downloads.bose.com", + "voice.api.bose.io", } hostsContent, err := client.Run("cat /etc/hosts") diff --git a/pkg/service/setup/setup_test.go b/pkg/service/setup/setup_test.go index 35ce630..cd98ffb 100644 --- a/pkg/service/setup/setup_test.go +++ b/pkg/service/setup/setup_test.go @@ -54,7 +54,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.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com\n192.168.1.100\tmedia.bose.io\n192.168.1.100\tdownloads.bose.com", nil + return "192.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com\n192.168.1.100\tmedia.bose.io\n192.168.1.100\tdownloads.bose.com\n192.168.1.100\tvoice.api.bose.io", nil } return "127.0.0.1 localhost", nil } @@ -132,7 +132,7 @@ func TestMigrateViaHosts_UpdateExisting(t *testing.T) { runCount++ if command == "cat /etc/hosts" { if runCount > 1 { - return "127.0.0.1 localhost\n192.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com\n192.168.1.100\tmedia.bose.io\n192.168.1.100\tdownloads.bose.com", nil + return "127.0.0.1 localhost\n192.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com\n192.168.1.100\tmedia.bose.io\n192.168.1.100\tdownloads.bose.com\n192.168.1.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 } @@ -648,7 +648,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.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com\n192.168.1.100\tmedia.bose.io\n192.168.1.100\tdownloads.bose.com", nil + return "192.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com\n192.168.1.100\tmedia.bose.io\n192.168.1.100\tdownloads.bose.com\n192.168.1.100\tvoice.api.bose.io", nil } return "127.0.0.1 localhost", nil } diff --git a/tests/interactions_20260502_missing_external.md b/tests/interactions_20260502_missing_external.md index 9e7979b..ca76199 100644 --- a/tests/interactions_20260502_missing_external.md +++ b/tests/interactions_20260502_missing_external.md @@ -18,11 +18,11 @@ These hosts are now in the DNS redirect list and the service handles them native | media.bose.io | `GET /bmx-icons/tunein/smallSvg.svg` | `HandleBmxIcons` | already in `static/media/bmx-icons/tunein/` | | media.bose.io | `GET /bmx-icons/tunein/top-menu/*.png` | `HandleBmxIcons` | 6 PNGs embedded (bubble, location, microphone, news, note, podcasts); speaker.png was 403 from CDN | -## Remaining: external host requiring action +## Stub implemented — requires AWS IoT integration to complete -| host | request | notes | -|-------------------|---------------------------|-----------------------------------------------------------------------------------------------------------------------------------------| -| voice.api.bose.io | `POST /alexa/certificate` | Alexa IoT cert provisioning; CSR + auth token → AWS IoT cert + endpoint; not in DNS redirect list; Alexa setup will fail after shutdown | +| host | request | handler | notes | +|-------------------|---------------------------|--------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| voice.api.bose.io | `POST /alexa/certificate` | `HandleAlexaCertificate` | Returns 501; logs device MAC. Full impl needs AWS IoT: parse CSR + token, call RegisterThing/CreateKeysAndCertificate, return cert + iot_endpoint. | ## Third-party analytics (no action needed)