mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
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>
101 lines
2.6 KiB
Go
101 lines
2.6 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|