fix(service): route embedded player TTS self-call over loopback

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) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-06-07 21:11:02 +02:00
co-authored by Claude Opus 4.8
parent 3dd39e85d4
commit 843ec732d5
3 changed files with 45 additions and 5 deletions
+20
View File
@@ -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 {
+1 -1
View File
@@ -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 <https://your-aftertouch-host>.",