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
+18
View File
@@ -161,6 +161,8 @@ PORT=8080
BIND_ADDR=
DISCOVERY_INTERFACE=
SOUNDTOUCH_DEVICES=
SERVICE_URL=
SERVICE_CA=
```
`SOUNDTOUCH_DEVICES` accepts a comma-separated list of IP addresses for manual
@@ -171,6 +173,22 @@ network:
SOUNDTOUCH_DEVICES=192.0.2.1,192.0.2.2
```
`SERVICE_URL` links `soundtouch-web` to your `soundtouch-service` instance,
which is required for Text-to-Speech ("Speak"). When the service is served
over HTTPS with its own self-signed certificate (the default), also set
`SERVICE_CA` to that CA certificate, or the proxied TTS call fails with
`x509: certificate signed by unknown authority`. The CA is the service's
`<dataDir>/certs/ca.crt` (also downloadable from `GET /setup/ca.crt`). For
example:
```bash
SERVICE_URL=https://soundtouch.local
SERVICE_CA=/var/lib/soundtouch-service/certs/ca.crt
```
With a plain `http://` `SERVICE_URL`, `SERVICE_CA` is unused (no TLS) and can
be left empty.
After editing the env file:
```bash
@@ -623,6 +623,52 @@ fmt.Printf("Current source: %s, status: %s\n",
nowPlaying.Source, nowPlaying.PlayStatus)
```
### ❌ soundtouch-web TTS fails with `certificate signed by unknown authority`
**Symptoms:**
```
TTS service request failed: Post "https://soundtouch.fritz.box/setup/tts/speak":
tls: failed to verify certificate: x509: certificate signed by unknown authority
```
**Cause:** TTS synthesis and the Bose app key live in `soundtouch-service`,
so `soundtouch-web` proxies the "Speak" action to the service. When the
service is served over HTTPS with its own self-signed certificate (the
default — see `GET /setup/ca.crt`), `soundtouch-web` doesn't trust that CA out
of the box, so the proxied call fails verification.
**Solution:** start `soundtouch-web` with `--service-ca` pointing at the
service's CA certificate (its `<dataDir>/certs/ca.crt`, or the file served at
`/setup/ca.crt`):
```bash
soundtouch-web \
--service-url https://soundtouch.fritz.box \
--service-ca /path/to/certs/ca.crt
```
`SERVICE_CA` is the equivalent environment variable. The CA is appended to the
system trust store, so a service URL that uses a publicly trusted certificate
needs no flag.
### ❌ soundtouch-web TTS returns `host ... is not a known device`
**Symptoms:**
```
TTS service returned 400: {"error":"host http://192.0.2.10:8090 is not a known device"}
```
**Cause:** the service only plays TTS on speakers it knows (an SSRF guard:
the target is matched against the service's device datastore, never taken
verbatim from the request).
**Solution:** make sure the target speaker is known to `soundtouch-service`
(discovered or manually added, and migrated to AfterTouch), not only to
`soundtouch-web`'s own discovery. Check with `GET /setup/devices` on the
service. (Recent `soundtouch-web` versions identify the speaker by its device
ID and a bare IP, so this error otherwise indicates the speaker simply isn't
registered with the service.)
---
## 📡 **WebSocket Issues**