fix(tts): move TTS endpoints from /mgmt to /setup (no Basic Auth)

The TTS speak/config endpoints were under /mgmt (Basic-Auth protected),
but the soundtouch-web proxy and CLI authenticated with their own
mgmt-password default (empty) while the service defaults to "change_me!",
so speaking from -web returned 401.

This was also inconsistent: the Google API key is configured via the
unauthenticated /setup/settings, and Play URL already proxies to /setup,
so gating only TTS playback behind mgmt auth made no sense. Move
/mgmt/tts/{speak,config} to /setup/tts/{speak,config} (LAN-trust, like
the rest of the setup surface), rename the handlers accordingly, and drop
the now-unused mgmt-credential plumbing from soundtouch-web and the CLI
tts command.

Verified: POST /setup/tts/speak now reaches the handler without auth
(502 only because the test speaker IP is unreachable; previously 401).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-31 22:35:31 +02:00
co-authored by Claude Opus 4.8
parent 8f2939a9a6
commit 169c1c5b9f
10 changed files with 31 additions and 64 deletions
+1 -13
View File
@@ -65,17 +65,6 @@ func ttsSpeakCmd() *cli.Command {
Aliases: []string{"v"},
Usage: "Playback volume (0-100, 0 = service default)",
},
&cli.StringFlag{
Name: "mgmt-username",
Usage: "Management API username for HTTP Basic Auth",
Value: "admin",
EnvVars: []string{"MGMT_USERNAME"},
},
&cli.StringFlag{
Name: "mgmt-password",
Usage: "Management API password for HTTP Basic Auth",
EnvVars: []string{"MGMT_PASSWORD"},
},
),
Action: ttsSpeak,
}
@@ -116,13 +105,12 @@ func ttsSpeak(c *cli.Context) error {
return fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequest(http.MethodPost, serviceURL+"/mgmt/tts/speak", bytes.NewReader(body))
req, err := http.NewRequest(http.MethodPost, serviceURL+"/setup/tts/speak", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(c.String("mgmt-username"), c.String("mgmt-password"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
+5 -5
View File
@@ -1321,11 +1321,6 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Post("/prime", server.HandleMgmtPrimeDeviceAmazon)
})
r.Route("/tts", func(r chi.Router) {
r.Post("/speak", server.HandleMgmtTTSSpeak)
r.Get("/config", server.HandleMgmtTTSConfig)
})
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
})
})
@@ -1338,6 +1333,11 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
r.Get("/settings", server.HandleGetSettings)
r.Post("/settings", server.HandleUpdateSettings)
// TTS lives under /setup (LAN-trust, like the rest of the integration
// surface and Play URL), not /mgmt: the API key is already configured
// via /setup/settings, and -web/CLI reach this without mgmt credentials.
r.Post("/tts/speak", server.HandleTTSSpeak)
r.Get("/tts/config", server.HandleTTSConfig)
r.Get("/info/{deviceId}", server.HandleGetDeviceInfo)
r.Get("/summary/{deviceId}", server.HandleGetMigrationSummary)
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
+2 -2
View File
@@ -59,7 +59,6 @@ GET /mgmt/devices/{deviceId}/events handlers.(
GET /mgmt/spotify/accounts handlers.(*Server).HandleMgmtSpotifyAccounts-fm
GET /mgmt/spotify/callback handlers.(*Server).HandleMgmtSpotifyCallback-fm
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
GET /mgmt/tts/config handlers.(*Server).HandleMgmtTTSConfig-fm
GET /setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
GET /setup/device-summary/{deviceId} handlers.(*Server).HandleDeviceSummary-fm
@@ -79,6 +78,7 @@ GET /setup/logging-settings handlers.(
GET /setup/logs handlers.(*Server).HandleGetLogs-fm
GET /setup/settings handlers.(*Server).HandleGetSettings-fm
GET /setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm
GET /setup/tts/config handlers.(*Server).HandleTTSConfig-fm
GET /setup/version handlers.(*Server).HandleGetVersionInfo-fm
GET /streaming/account/{account}/device/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
GET /streaming/account/{account}/device/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
@@ -132,7 +132,6 @@ POST /mgmt/spotify/confirm handlers.(
POST /mgmt/spotify/entity handlers.(*Server).HandleMgmtSpotifyEntity-fm
POST /mgmt/spotify/init handlers.(*Server).HandleMgmtSpotifyInit-fm
POST /mgmt/spotify/prime handlers.(*Server).HandleMgmtPrimeDevice-fm
POST /mgmt/tts/speak handlers.(*Server).HandleMgmtTTSSpeak-fm
POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs handlers.(*Server).HandleBoseAccountToken-fm
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token handlers.(*Server).HandleBoseLegacyToken-fm
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1 handlers.(*Server).HandleBoseToken-fm
@@ -155,6 +154,7 @@ POST /setup/test-connection/{deviceId} handlers.(
POST /setup/test-dns/{deviceId} handlers.(*Server).HandleTestDNSRedirection-fm
POST /setup/test-hosts/{deviceId} handlers.(*Server).HandleTestHostsRedirection-fm
POST /setup/trust-ca/{deviceId} handlers.(*Server).HandleTrustCACert-fm
POST /setup/tts/speak handlers.(*Server).HandleTTSSpeak-fm
POST /streaming/account handlers.(*Server).HandleMargeCreateAccount-fm
POST /streaming/account/login handlers.(*Server).HandleMargeLogin-fm
POST /streaming/account/{account}/device/ handlers.(*Server).HandleMargeAddDevice-fm
-13
View File
@@ -81,17 +81,6 @@ func main() {
Usage: "AfterTouch service base URL (e.g. https://soundtouch.local). Required for custom stream URLs to work as presets via LOCAL_INTERNET_RADIO",
EnvVars: []string{"SERVICE_URL"},
},
&cli.StringFlag{
Name: "mgmt-username",
Usage: "AfterTouch management API username, used to proxy TTS to the service's /mgmt endpoints",
Value: "admin",
EnvVars: []string{"MGMT_USERNAME"},
},
&cli.StringFlag{
Name: "mgmt-password",
Usage: "AfterTouch management API password, used to proxy TTS to the service's /mgmt endpoints",
EnvVars: []string{"MGMT_PASSWORD"},
},
},
Action: func(c *cli.Context) error {
port := c.String("port")
@@ -126,8 +115,6 @@ func main() {
webApp.Date = date
webApp.RepoURL = repoURL
webApp.ServiceURL = strings.TrimRight(c.String("service-url"), "/")
webApp.MgmtUsername = c.String("mgmt-username")
webApp.MgmtPassword = c.String("mgmt-password")
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName)
@@ -308,10 +308,11 @@ soundtouch-service
### Triggering speech
Service management API (Basic Auth):
Service HTTP API (under `/setup`, LAN-trust like the rest of the setup surface,
no auth):
```bash
curl -u admin:change_me! -X POST http://soundtouch.local:8000/mgmt/tts/speak \
curl -X POST http://soundtouch.local:8000/setup/tts/speak \
-H 'Content-Type: application/json' \
-d '{"host":"192.0.2.100","text":"Dinner is ready"}'
```
@@ -328,9 +329,9 @@ soundtouch-cli tts speak \
--text "Dinner is ready"
```
Web UI: the per-device controls include a "Say something…" box. soundtouch-web
proxies it to the service, so start it with `--service-url` (and `--mgmt-username`
/ `--mgmt-password` if you changed the defaults).
Web UI: the TTS source view (and the Play URL view) include a "Say something…"
box. soundtouch-web proxies it to the service, so start it with `--service-url`
(or enter the service URL in the view).
### Notes and limitations
+4 -4
View File
@@ -25,9 +25,9 @@ type ttsSpeakRequest struct {
Volume *int `json:"volume,omitempty"`
}
// HandleMgmtTTSSpeak synthesizes the requested text (or builds a direct URL),
// HandleTTSSpeak synthesizes the requested text (or builds a direct URL),
// then tells the target speaker to play it via the /speaker endpoint.
func (s *Server) HandleMgmtTTSSpeak(w http.ResponseWriter, r *http.Request) {
func (s *Server) HandleTTSSpeak(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
svc := s.ttsSvc()
@@ -147,8 +147,8 @@ func (s *Server) HandleTTSMedia(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(audio)
}
// HandleMgmtTTSConfig reports the active TTS configuration (no secrets).
func (s *Server) HandleMgmtTTSConfig(w http.ResponseWriter, _ *http.Request) {
// HandleTTSConfig reports the active TTS configuration (no secrets).
func (s *Server) HandleTTSConfig(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
svc := s.ttsSvc()
+10 -10
View File
@@ -23,8 +23,8 @@ func ttsTestRouter(t *testing.T, baseURL string) (*chi.Mux, *Server) {
r := chi.NewRouter()
r.Get("/media/tts/{id}", server.HandleTTSMedia)
r.Post("/mgmt/tts/speak", server.HandleMgmtTTSSpeak)
r.Get("/mgmt/tts/config", server.HandleMgmtTTSConfig)
r.Post("/setup/tts/speak", server.HandleTTSSpeak)
r.Get("/setup/tts/config", server.HandleTTSConfig)
return r, server
}
@@ -40,10 +40,10 @@ func mockCloudTTS(t *testing.T, audio string) *httptest.Server {
}))
}
func TestHandleMgmtTTSConfigNotConfigured(t *testing.T) {
func TestHandleTTSConfigNotConfigured(t *testing.T) {
r, _ := ttsTestRouter(t, "http://localhost:8001")
req := httptest.NewRequest(http.MethodGet, "/mgmt/tts/config", nil)
req := httptest.NewRequest(http.MethodGet, "/setup/tts/config", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
@@ -61,11 +61,11 @@ func TestHandleMgmtTTSConfigNotConfigured(t *testing.T) {
}
}
func TestHandleMgmtTTSConfigConfigured(t *testing.T) {
func TestHandleTTSConfigConfigured(t *testing.T) {
r, server := ttsTestRouter(t, "http://localhost:8001")
server.SetTTSService(tts.NewService(tts.NewTranslateProvider(), tts.Config{AppKey: "k", DefaultLanguage: "EN"}))
req := httptest.NewRequest(http.MethodGet, "/mgmt/tts/config", nil)
req := httptest.NewRequest(http.MethodGet, "/setup/tts/config", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
@@ -139,10 +139,10 @@ func TestHandleTTSMediaMissingClip(t *testing.T) {
}
}
func TestHandleMgmtTTSSpeakNotConfigured(t *testing.T) {
func TestHandleTTSSpeakNotConfigured(t *testing.T) {
r, _ := ttsTestRouter(t, "http://localhost:8001")
req := httptest.NewRequest(http.MethodPost, "/mgmt/tts/speak", strings.NewReader(`{"host":"192.0.2.10","text":"hi"}`))
req := httptest.NewRequest(http.MethodPost, "/setup/tts/speak", strings.NewReader(`{"host":"192.0.2.10","text":"hi"}`))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
@@ -151,7 +151,7 @@ func TestHandleMgmtTTSSpeakNotConfigured(t *testing.T) {
}
}
func TestHandleMgmtTTSSpeakValidation(t *testing.T) {
func TestHandleTTSSpeakValidation(t *testing.T) {
r, server := ttsTestRouter(t, "http://localhost:8001")
server.SetTTSService(tts.NewService(tts.NewTranslateProvider(), tts.Config{AppKey: "k"}))
@@ -167,7 +167,7 @@ func TestHandleMgmtTTSSpeakValidation(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/mgmt/tts/speak", strings.NewReader(tc.body))
req := httptest.NewRequest(http.MethodPost, "/setup/tts/speak", strings.NewReader(tc.body))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
-5
View File
@@ -42,11 +42,6 @@ type WebApp struct {
RepoURL string
ServiceURL string
// Management API credentials for proxying to the AfterTouch service's
// Basic-Auth-protected /mgmt endpoints (e.g. TTS synthesis).
MgmtUsername string
MgmtPassword string
discoveryStatus atomic.Value // stores *webtypes.DiscoveryStatus
}
+2 -6
View File
@@ -14,7 +14,7 @@ import (
// HandleAPISpeakText synthesizes and plays text on a device. The Web UI talks
// to speakers directly for most controls, but TTS synthesis (Google Cloud) and
// the Bose app_key live in the AfterTouch service, so this proxies to the
// service's /mgmt/tts/speak endpoint, targeting the device by its IP/host.
// service's /setup/tts/speak endpoint, targeting the device by its IP/host.
func (app *WebApp) HandleAPISpeakText(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
@@ -83,7 +83,7 @@ func (app *WebApp) HandleAPISpeakText(w http.ResponseWriter, r *http.Request) {
return
}
upstream, err := http.NewRequestWithContext(r.Context(), http.MethodPost, serviceURL+"/mgmt/tts/speak", bytes.NewReader(body))
upstream, err := http.NewRequestWithContext(r.Context(), http.MethodPost, serviceURL+"/setup/tts/speak", bytes.NewReader(body))
if err != nil {
app.sendError(w, "Failed to build TTS request", http.StatusInternalServerError)
return
@@ -91,10 +91,6 @@ func (app *WebApp) HandleAPISpeakText(w http.ResponseWriter, r *http.Request) {
upstream.Header.Set("Content-Type", "application/json")
if app.MgmtUsername != "" || app.MgmtPassword != "" {
upstream.SetBasicAuth(app.MgmtUsername, app.MgmtPassword)
}
resp, err := http.DefaultClient.Do(upstream)
if err != nil {
app.sendError(w, fmt.Sprintf("TTS service request failed: %v", err), http.StatusBadGateway)
+1 -1
View File
@@ -79,7 +79,7 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov
// Custom URL playback
r.Post("/api/play-url/{id}", app.HandlePlayURL)
// Text-to-speech (proxied to the AfterTouch service's /mgmt/tts/speak)
// Text-to-speech (proxied to the AfterTouch service's /setup/tts/speak)
r.Post("/api/device-speak/{id}", app.HandleAPISpeakText)
// SPA routes — serve index.html for client-side routing