mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 09:06:14 +00:00
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:
co-authored by
Claude Opus 4.8
parent
7051793e81
commit
d94b1bc067
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,12 @@ set -euo pipefail
|
||||
# HTTP_PORT=8081 \
|
||||
# bash install-web.sh
|
||||
#
|
||||
# # With an AfterTouch service link for TTS (HTTPS + self-signed CA):
|
||||
# sudo \
|
||||
# SERVICE_URL=https://soundtouch.local \
|
||||
# SERVICE_CA=/var/lib/soundtouch-service/certs/ca.crt \
|
||||
# bash install-web.sh
|
||||
#
|
||||
# Or with a version argument to perform an update:
|
||||
# sudo bash install-web.sh v0.104.0
|
||||
#
|
||||
@@ -48,6 +54,13 @@ BIND_ADDR="${BIND_ADDR:-}"
|
||||
DISCOVERY_INTERFACE="${DISCOVERY_INTERFACE:-}"
|
||||
SOUNDTOUCH_DEVICES="${SOUNDTOUCH_DEVICES:-}"
|
||||
|
||||
# Optional AfterTouch service link (needed for TTS / "Speak").
|
||||
# SERVICE_URL: base URL of soundtouch-service, e.g. https://soundtouch.local
|
||||
# SERVICE_CA: path to the service CA cert when it serves HTTPS with its own
|
||||
# self-signed certificate, e.g. /var/lib/soundtouch-service/certs/ca.crt
|
||||
SERVICE_URL="${SERVICE_URL:-}"
|
||||
SERVICE_CA="${SERVICE_CA:-}"
|
||||
|
||||
# Override if you want to force a specific asset suffix:
|
||||
# ARCH_ASSET=linux-armv7|linux-arm64|linux-amd64
|
||||
ARCH_ASSET="${ARCH_ASSET:-}"
|
||||
@@ -179,6 +192,7 @@ self_update() {
|
||||
|
||||
export IS_SELF_UPDATE="true"
|
||||
export VERSION HTTP_PORT BIND_ADDR DISCOVERY_INTERFACE SOUNDTOUCH_DEVICES
|
||||
export SERVICE_URL SERVICE_CA
|
||||
export BIN_PATH CONFIG_DIR ENV_FILE SERVICE_USER SERVICE_GROUP
|
||||
|
||||
exec "${SCRIPT_PATH}" "$@"
|
||||
@@ -192,6 +206,8 @@ write_env_file() {
|
||||
"BIND_ADDR=${BIND_ADDR}"
|
||||
"DISCOVERY_INTERFACE=${DISCOVERY_INTERFACE}"
|
||||
"SOUNDTOUCH_DEVICES=${SOUNDTOUCH_DEVICES}"
|
||||
"SERVICE_URL=${SERVICE_URL}"
|
||||
"SERVICE_CA=${SERVICE_CA}"
|
||||
)
|
||||
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
|
||||
Reference in New Issue
Block a user