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 .",