feat(alexa): stub POST /alexa/certificate with 501 and add voice.api.bose.io to DNS (#200)

Registers HandleAlexaCertificate on POST /alexa/certificate. The handler
logs the device MAC from the request body and returns 501 Not
Implemented with a JSON error explaining that AWS IoT integration is
required to provision Alexa device certificates.

Adds voice.api.bose.io to both /etc/hosts domain lists in setup.go (DNS
intercept was already covered by the bose.io wildcard entry in dns.go).

Relates to https://github.com/gesellix/Bose-SoundTouch/discussions/84

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-02 21:37:30 +02:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent e99c04888c
commit 16ab9dbba1
7 changed files with 54 additions and 8 deletions
+1
View File
@@ -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) {
+1
View File
@@ -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
+42
View File
@@ -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."}`))
}
+1 -1
View File
@@ -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
}
+2
View File
@@ -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")
+3 -3
View File
@@ -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
}
@@ -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)