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
+30 -3
View File
@@ -93,11 +93,38 @@ go build -o soundtouch-web
### Command Line Options
```
-port string Web server port (default "8080")
-host string Specific SoundTouch device host (optional, enables single-device mode)
-help Show help information
--port, -p string HTTP port to listen on (default "8080", env PORT)
--bind string Address for the HTTP listener: host, IP, or interface name (env BIND_ADDR)
--interface string Network interface name for mDNS/UPnP discovery (env DISCOVERY_INTERFACE)
--devices strings SoundTouch device IP(s) to add manually, repeatable (env SOUNDTOUCH_DEVICES)
--service-url string AfterTouch service base URL, e.g. https://soundtouch.local (env SERVICE_URL)
--service-ca string Path to the AfterTouch service CA certificate (PEM) to trust (env SERVICE_CA)
--help, -h Show help information
```
### Text-to-Speech (TTS)
TTS synthesis and the Bose `app_key` live in the AfterTouch service, not in
soundtouch-web, so the "Speak" feature proxies to the service's
`/setup/tts/speak` endpoint. To use it, point soundtouch-web at the service
with `--service-url`.
When the service is served over HTTPS with its own self-signed certificate
(the default), soundtouch-web also needs to trust the service's CA, or the
proxied call fails with `x509: certificate signed by unknown authority`. Pass
the CA with `--service-ca`; it is the service's `<dataDir>/certs/ca.crt`:
```bash
soundtouch-web \
--service-url https://soundtouch.fritz.box \
--service-ca /path/to/certs/ca.crt
```
The CA is appended to the system trust store, so a service URL that uses a
publicly trusted certificate keeps working without the flag. The target
speaker must be known to the service (it resolves the speaker against its own
device datastore).
## Usage
### Accessing the Interface
+16
View File
@@ -81,6 +81,11 @@ func main() {
Usage: "AfterTouch service base URL (e.g. https://soundtouch.local). Required for custom stream URLs to work as presets via LOCAL_INTERNET_RADIO",
EnvVars: []string{"SERVICE_URL"},
},
&cli.StringFlag{
Name: "service-ca",
Usage: "Path to the AfterTouch service CA certificate (PEM) to trust for server-side calls such as TTS. Typically the service's <dataDir>/certs/ca.crt. Appended to the system trust store",
EnvVars: []string{"SERVICE_CA"},
},
},
Action: func(c *cli.Context) error {
port := c.String("port")
@@ -116,6 +121,17 @@ func main() {
webApp.RepoURL = repoURL
webApp.ServiceURL = strings.TrimRight(c.String("service-url"), "/")
if caPath := c.String("service-ca"); caPath != "" {
client, err := soundtouchweb.NewServiceHTTPClient(caPath)
if err != nil {
log.Fatalf("--service-ca: %v", err)
}
webApp.ServiceClient = client
log.Printf("Trusting AfterTouch service CA from %s", sanitizeLog(caPath))
}
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName)
// Discover devices on startup