From 843ec732d58424569ba25688ea343f7d181640f2 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 7 Jun 2026 21:06:24 +0200 Subject: [PATCH] fix(service): route embedded player TTS self-call over loopback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embedded player's TTS proxy made a server-side call back to the service over the public ServiceURL. When that URL is HTTPS with the service's self-signed CA, the call failed with "x509: certificate signed by unknown authority" — the service didn't trust its own CA. Route the player's own server-side self-calls to the service's loopback HTTP listener instead (new WebApp.InternalServiceURL, used via proxyServiceURL()). Loopback is plain HTTP, so it needs no CA and works on HTTP and HTTPS deployments alike, including before the CA is generated, and it doesn't depend on the public URL being routable from inside the service. ServiceURL stays public: Play URL bakes it into the stream URLs the speaker fetches, and the UI displays it. config.port is always the plain-HTTP listener (http.Serve); TLS lives on a separate httpsAddr, so the loopback URL can never hit a TLS-only socket. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/soundtouch-service/main.go | 28 +++++++++++++++++++---- pkg/service/soundtouchweb/handler.go | 20 ++++++++++++++++ pkg/service/soundtouchweb/handlers_tts.go | 2 +- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 9a91f3a..592e038 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -590,7 +590,15 @@ func main() { // Embedded web UI (soundtouch-player): LAN control UI under /app, control // API under /api/control. Same LAN-trust tier as /setup, no auth. - webApp := newEmbeddedWebApp(server, config.serverURL, ds) + // Server-side self-calls (TTS proxy) use the service's own loopback + // HTTP listener so they never depend on TLS / the service CA. + loopbackHost := config.bindAddr + if loopbackHost == "" { + loopbackHost = "127.0.0.1" + } + + internalURL := "http://" + net.JoinHostPort(loopbackHost, config.port) + webApp := newEmbeddedWebApp(server, config.serverURL, internalURL, ds) r := setupRouter(server, stockholmHandler, webApp) @@ -1133,8 +1141,10 @@ func startDeviceDiscovery(server *handlers.Server) { } // newEmbeddedWebApp builds the soundtouch-player application for embedding in the -// service router: release metadata from the build vars, a loopback ServiceURL -// for the TTS / Play URL proxy (plain HTTP, no CA trust needed), and device +// service router: release metadata from the build vars, the service's public +// ServiceURL (used by Play URL for speaker-fetched stream URLs and shown in the +// UI), a loopback InternalServiceURL for the player's own server-side self-calls +// (the TTS proxy) so they never depend on TLS or the service CA, and device // state sourced entirely from the service. // // The web UI shares the service's discovery rather than running its own (the @@ -1142,13 +1152,23 @@ func startDeviceDiscovery(server *handlers.Server) { // TriggerDiscovery runs the service sweep on a UI-initiated "discover", and the // devices-changed hook re-syncs the UI registry whenever the service's // discovery or a manual add changes the set. -func newEmbeddedWebApp(server *handlers.Server, serverURL string, ds *datastore.DataStore) *soundtouchweb.WebApp { +func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, ds *datastore.DataStore) *soundtouchweb.WebApp { webApp := soundtouchweb.NewWebApp() webApp.Version = version webApp.Commit = commit webApp.Date = date webApp.RepoURL = repoURL webApp.ServiceURL = strings.TrimRight(serverURL, "/") + + // The player's own server-side calls (the TTS proxy hits + // /api/setup/tts/speak) go to the service's loopback HTTP listener, not the + // public ServiceURL. That avoids the "service doesn't trust its own CA" + // x509 failure entirely: loopback is plain HTTP, so it needs no CA and + // works on HTTP and HTTPS deployments alike — and before the CA is even + // generated. ServiceURL stays the public URL because Play URL bakes it into + // stream URLs the speaker fetches and the UI displays it. + webApp.InternalServiceURL = internalURL + webApp.ExtraDeviceHosts = func() []string { devices, listErr := ds.ListAllDevices() if listErr != nil { diff --git a/pkg/service/soundtouchweb/handler.go b/pkg/service/soundtouchweb/handler.go index 67a8f89..920feec 100644 --- a/pkg/service/soundtouchweb/handler.go +++ b/pkg/service/soundtouchweb/handler.go @@ -43,6 +43,14 @@ type WebApp struct { RepoURL string ServiceURL string + // InternalServiceURL is the base URL the player uses for its own + // server-side calls back to the AfterTouch service (currently the TTS + // proxy at /api/setup/tts/speak). The embedded build sets it to the + // service's loopback HTTP listener so those self-calls never depend on TLS + // or the service's self-signed CA. Standalone soundtouch-player leaves it + // empty and falls back to ServiceURL. + InternalServiceURL string + // ServiceClient is used for server-side calls to the AfterTouch service // (currently the TTS proxy). When nil, serviceHTTPClient falls back to // http.DefaultClient. Set it via NewServiceHTTPClient to trust the @@ -84,6 +92,18 @@ func (app *WebApp) serviceHTTPClient() *http.Client { return http.DefaultClient } +// proxyServiceURL returns the base URL for the player's own server-side calls +// back to the AfterTouch service (the TTS proxy). It prefers the loopback +// InternalServiceURL (plain HTTP, no CA needed) and falls back to the public +// ServiceURL for the standalone build where no internal URL is set. +func (app *WebApp) proxyServiceURL() string { + if app.InternalServiceURL != "" { + return app.InternalServiceURL + } + + return app.ServiceURL +} + // DeviceEntry pairs a device id with its connection. Used by // DeviceSnapshot so callers can iterate without holding the lock. type DeviceEntry struct { diff --git a/pkg/service/soundtouchweb/handlers_tts.go b/pkg/service/soundtouchweb/handlers_tts.go index fa03e7f..4cd0c67 100644 --- a/pkg/service/soundtouchweb/handlers_tts.go +++ b/pkg/service/soundtouchweb/handlers_tts.go @@ -75,7 +75,7 @@ func (app *WebApp) HandleAPISpeakText(w http.ResponseWriter, r *http.Request) { // (that would let any LAN caller use this endpoint as an SSRF proxy). This // differs from Play URL, where the URL is handed to the speaker, not fetched // by soundtouch-player. - serviceURL := strings.TrimRight(app.ServiceURL, "/") + serviceURL := strings.TrimRight(app.proxyServiceURL(), "/") if serviceURL == "" { app.sendError(w, "TTS requires the AfterTouch service URL. Start soundtouch-player with --service-url .",