fix(web): trust service CA and send a known target for TTS

soundtouch-web's "Speak" feature proxies to the AfterTouch service's
/setup/tts/speak endpoint. Two issues blocked it end to end.

1. TLS: the proxy used http.DefaultClient, which trusts only system
   roots, so the HTTPS call to a service using its own self-signed CA
   failed with "x509: certificate signed by unknown authority". Add a
   --service-ca flag (SERVICE_CA env) that loads the CA PEM, appends it
   to the system pool, and uses a custom client for the TTS call.

2. Target: soundtouch-web sent device.Client.Host() (a full base URL
   like http://ip:8090), but the service's SSRF guard exact-matches the
   target against bare datastore IPs, returning "host ... is not a known
   device". Prefer the device ID (the canonical key) and send a bare-IP
   host fallback. Also normalize the incoming host in resolveTTSHost so a
   URL/host:port form still resolves; it still only ever returns a
   datastore IP, so the SSRF guarantee is unchanged.

Adds unit tests for the CA client builder, hostOnly, and resolveTTSHost
(including the preserved unknown-host/device rejections). Documents
--service-ca in the soundtouch-web README and TROUBLESHOOTING guide.
Wires SERVICE_URL and SERVICE_CA (empty defaults) into the Raspberry Pi
install-web.sh env file and documents them in the Pi guide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-31 23:37:33 +02:00
co-authored by Claude Opus 4.8
parent 7051793e81
commit d94b1bc067
11 changed files with 416 additions and 6 deletions
+27 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
@@ -152,9 +153,15 @@ func (s *Server) HandleSpeakerAuth(w http.ResponseWriter, _ *http.Request) {
// baseURL -> the outbound request). Match by DeviceID, or by Host equal to a
// known device's IP; either way the returned string is the datastore's
// IPAddress, not the caller-supplied value.
//
// The incoming Host is normalized to a bare host (scheme and port stripped)
// before the match so callers that pass a base URL (e.g. "http://ip:8090",
// which is what a client's Host() returns) still resolve. This only makes the
// needle comparable to the datastore's bare IPs; the result is still always a
// datastore IP, so the SSRF guarantee is unchanged.
func (s *Server) resolveTTSHost(req ttsSpeakRequest) (string, error) {
deviceID := strings.TrimSpace(req.DeviceID)
host := strings.TrimSpace(req.Host)
host := ttsHostOnly(strings.TrimSpace(req.Host))
if deviceID == "" && host == "" {
return "", fmt.Errorf("either deviceId or host is required")
@@ -183,6 +190,25 @@ func (s *Server) resolveTTSHost(req ttsSpeakRequest) (string, error) {
return "", fmt.Errorf("host %s is not a known device", host)
}
// ttsHostOnly reduces a base URL or host:port to a bare host so it can be
// compared against the datastore's bare IPs. Inputs that are already bare are
// returned unchanged. The empty string maps to the empty string.
func ttsHostOnly(raw string) string {
if raw == "" {
return ""
}
if u, err := url.Parse(raw); err == nil && u.Host != "" {
return u.Hostname()
}
if host, _, err := net.SplitHostPort(raw); err == nil {
return host
}
return raw
}
// HandleTTSMedia serves a synthesized clip by id for the speaker to fetch.
func (s *Server) HandleTTSMedia(w http.ResponseWriter, r *http.Request) {
svc := s.ttsSvc()
+59
View File
@@ -9,6 +9,7 @@ import (
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/tts"
"github.com/go-chi/chi/v5"
@@ -181,3 +182,61 @@ func TestHandleTTSSpeakValidation(t *testing.T) {
})
}
}
// TestResolveTTSHost covers the SSRF guard plus the host-normalization that
// lets a base URL (what a client's Host() returns, e.g. soundtouch-web sends
// "http://ip:8090") resolve to a known device. The result is always the
// datastore's bare IP, never the caller's value.
func TestResolveTTSHost(t *testing.T) {
ds := datastore.NewDataStore(t.TempDir())
if err := ds.Initialize(); err != nil {
t.Fatalf("Initialize: %v", err)
}
if err := ds.SaveDeviceInfo("1000001", "DEVICEID01", &models.ServiceDeviceInfo{
DeviceID: "DEVICEID01",
AccountID: "1000001",
IPAddress: "192.0.2.10",
}); err != nil {
t.Fatalf("SaveDeviceInfo: %v", err)
}
server := NewServer(ds, nil, "http://localhost:8001", false, false, false)
cases := []struct {
name string
req ttsSpeakRequest
want string
wantErr bool
}{
{"bare ip", ttsSpeakRequest{Host: "192.0.2.10"}, "192.0.2.10", false},
{"base url", ttsSpeakRequest{Host: "http://192.0.2.10:8090"}, "192.0.2.10", false},
{"host:port", ttsSpeakRequest{Host: "192.0.2.10:8090"}, "192.0.2.10", false},
{"by device id", ttsSpeakRequest{DeviceID: "DEVICEID01"}, "192.0.2.10", false},
// SSRF guard intact: unknown targets are rejected even in URL form.
{"unknown host url", ttsSpeakRequest{Host: "http://203.0.113.99:8090"}, "", true},
{"unknown device", ttsSpeakRequest{DeviceID: "NOPE"}, "", true},
{"no target", ttsSpeakRequest{}, "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := server.resolveTTSHost(tc.req)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}
}
+17
View File
@@ -42,9 +42,26 @@ type WebApp struct {
RepoURL string
ServiceURL 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
// service's self-signed CA.
ServiceClient *http.Client
discoveryStatus atomic.Value // stores *webtypes.DiscoveryStatus
}
// serviceHTTPClient returns the client used for outbound calls to the
// AfterTouch service, falling back to http.DefaultClient when no CA-trusting
// client was configured.
func (app *WebApp) serviceHTTPClient() *http.Client {
if app.ServiceClient != nil {
return app.ServiceClient
}
return http.DefaultClient
}
// DeviceEntry pairs a device id with its connection. Used by
// DeviceSnapshot so callers can iterate without holding the lock.
type DeviceEntry struct {
+38 -2
View File
@@ -5,12 +5,36 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"github.com/go-chi/chi/v5"
)
// hostOnly reduces a base URL or host:port to a bare host (IP or hostname).
// device.Client.Host() returns a full base URL like "http://192.168.0.2:8090",
// but the AfterTouch service matches the TTS target against bare datastore IPs,
// so we strip the scheme and port before sending it. Inputs that are already
// bare ("192.168.0.2") are returned unchanged.
func hostOnly(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if u, err := url.Parse(raw); err == nil && u.Host != "" {
return u.Hostname()
}
if host, _, err := net.SplitHostPort(raw); err == nil {
return host
}
return raw
}
// 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
@@ -60,10 +84,22 @@ func (app *WebApp) HandleAPISpeakText(w http.ResponseWriter, r *http.Request) {
return
}
// Identify the target speaker for the service. Prefer the DeviceID (the
// canonical, unambiguous key the service matches in its datastore). Also
// send a bare-IP host as a fallback: device.Client.Host() is a full base
// URL (http://ip:8090), which the service's exact-match SSRF guard
// (resolveTTSHost) would reject, so strip it down to host-only.
payload := map[string]interface{}{
"host": device.Client.Host(),
"text": req.Text,
}
if device.DeviceInfo != nil && device.DeviceInfo.DeviceID != "" {
payload["deviceId"] = device.DeviceInfo.DeviceID
}
if h := hostOnly(device.Client.Host()); h != "" {
payload["host"] = h
}
if req.Language != "" {
payload["language"] = req.Language
}
@@ -90,7 +126,7 @@ func (app *WebApp) HandleAPISpeakText(w http.ResponseWriter, r *http.Request) {
upstream.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(upstream)
resp, err := app.serviceHTTPClient().Do(upstream)
if err != nil {
app.sendError(w, fmt.Sprintf("TTS service request failed: %v", err), http.StatusBadGateway)
return
@@ -0,0 +1,49 @@
package soundtouchweb
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"os"
"time"
)
// NewServiceHTTPClient builds an *http.Client that trusts the AfterTouch
// service's CA certificate (PEM at caPath) in addition to the system trust
// store. soundtouch-web uses it for the only server-side call it makes to the
// service (the TTS proxy in handlers_tts.go): the service serves a self-signed
// certificate signed by its own "AfterTouch Local Root CA", which isn't in any
// system trust store, so http.DefaultClient would reject it with
// "x509: certificate signed by unknown authority".
//
// The CA is appended to a copy of the system pool (not a fresh empty one) so a
// deployment whose service URL happens to use a publicly trusted certificate
// keeps working.
func NewServiceHTTPClient(caPath string) (*http.Client, error) {
pem, err := os.ReadFile(caPath)
if err != nil {
return nil, fmt.Errorf("read CA: %w", err)
}
pool, err := x509.SystemCertPool()
if err != nil || pool == nil {
pool = x509.NewCertPool()
}
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("no valid certificate found in %s", caPath)
}
return &http.Client{
// TTS round-trips through Google Cloud synthesis and speaker playback,
// so allow more than the bare connect time.
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: pool,
MinVersion: tls.VersionTLS12,
},
},
}, nil
}
@@ -0,0 +1,100 @@
package soundtouchweb
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net/http"
"os"
"path/filepath"
"testing"
"time"
)
// writeTestCA generates a throwaway self-signed CA and returns its PEM path.
func writeTestCA(t *testing.T) string {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "Test CA"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IsCA: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("create cert: %v", err)
}
path := filepath.Join(t.TempDir(), "ca.crt")
if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil {
t.Fatalf("write ca: %v", err)
}
return path
}
func TestNewServiceHTTPClientValidCA(t *testing.T) {
client, err := NewServiceHTTPClient(writeTestCA(t))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
tr, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("transport type = %T, want *http.Transport", client.Transport)
}
if tr.TLSClientConfig == nil || tr.TLSClientConfig.RootCAs == nil {
t.Fatal("expected a non-nil RootCAs pool")
}
if client.Timeout == 0 {
t.Fatal("expected a non-zero timeout")
}
}
func TestNewServiceHTTPClientMissingFile(t *testing.T) {
if _, err := NewServiceHTTPClient(filepath.Join(t.TempDir(), "absent.crt")); err == nil {
t.Fatal("expected an error for a missing file")
}
}
func TestNewServiceHTTPClientNoCertInFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "junk.crt")
if err := os.WriteFile(path, []byte("not a pem certificate"), 0o600); err != nil {
t.Fatalf("write junk: %v", err)
}
if _, err := NewServiceHTTPClient(path); err == nil {
t.Fatal("expected an error for a file with no certificate")
}
}
func TestHostOnly(t *testing.T) {
cases := map[string]string{
"http://192.168.178.35:8090": "192.168.178.35",
"https://soundtouch.local": "soundtouch.local",
"192.168.178.35:8090": "192.168.178.35",
"192.168.178.35": "192.168.178.35",
"": "",
}
for in, want := range cases {
if got := hostOnly(in); got != want {
t.Errorf("hostOnly(%q) = %q, want %q", in, got, want)
}
}
}