mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
feat(tls): persist TLSExtraHosts + Settings UI + speaker_marge_url QuickFix
Operators who deploy AfterTouch on an IP-only host (no DNS hostname) and who get a speaker_marge_url health warning previously had to SSH in, edit their systemd unit or docker-compose, add --tls-extra-host, and restart. The fix is now reachable from the UI: - datastore.Settings gains TLSExtraHosts []string. At startup applyPersistedSettings merges CLI/env values (still authoritative) with persisted ones, deduplicating while preserving order. - /setup/settings (GET) exposes tls_extra_hosts (editable list) and tls_san_hosts (the full effective SAN list, read-only). - /setup/settings (POST) accepts tls_extra_hosts (*[]string so callers can distinguish "field omitted" from "explicitly empty"). - Settings tab grows a "TLS extra hosts" textarea + an info panel explaining the restart-required dance. - speaker_marge_url emits a QuickFix labelled "Add <host> to TLS hosts" alongside the existing CLI manual command. The fix re-probes the device's /info, extracts the margeURL host, and appends it to the persisted list — race-safe against stale findings. - HTTPS-SETUP.md documents both paths. Tests cover: merge dedup + ordering + whitespace, the new QuickFix emission shape, and the margeURL host extraction across HTTPS/HTTP/bare input forms. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
377fa9ceda
commit
3a0b30bc33
@@ -810,9 +810,44 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
|
||||
// CLI/env args take precedence; only apply persisted credentials when not set via CLI.
|
||||
applyPersistedMusicServiceCredentials(config, persisted)
|
||||
|
||||
config.tlsExtraHosts = mergeTLSExtraHosts(config.tlsExtraHosts, persisted.TLSExtraHosts)
|
||||
|
||||
return persisted
|
||||
}
|
||||
|
||||
// mergeTLSExtraHosts merges the CLI/env-supplied hosts with the persisted
|
||||
// list. CLI/env wins (so an operator who pinned a host via systemd unit
|
||||
// always sees it applied); persisted values are additive. Returns a
|
||||
// deduplicated, order-preserving slice with CLI/env entries first.
|
||||
func mergeTLSExtraHosts(cli, persisted []string) []string {
|
||||
seen := make(map[string]bool, len(cli)+len(persisted))
|
||||
out := make([]string, 0, len(cli)+len(persisted))
|
||||
|
||||
for _, h := range cli {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" || seen[h] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[h] = true
|
||||
|
||||
out = append(out, h)
|
||||
}
|
||||
|
||||
for _, h := range persisted {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" || seen[h] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[h] = true
|
||||
|
||||
out = append(out, h)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// applyPersistedMusicServiceCredentials fills in music service credentials from persisted
|
||||
// settings when they have not been supplied via CLI flags or environment variables.
|
||||
func applyPersistedMusicServiceCredentials(config *serviceConfig, persisted datastore.Settings) {
|
||||
|
||||
@@ -90,3 +90,64 @@ func TestApplyPersistedSettings(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMergeTLSExtraHosts(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cli []string
|
||||
persisted []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "CLI only",
|
||||
cli: []string{"a.example"},
|
||||
persisted: nil,
|
||||
want: []string{"a.example"},
|
||||
},
|
||||
{
|
||||
name: "Persisted only",
|
||||
cli: nil,
|
||||
persisted: []string{"b.example"},
|
||||
want: []string{"b.example"},
|
||||
},
|
||||
{
|
||||
name: "CLI wins ordering, persisted appended",
|
||||
cli: []string{"a.example"},
|
||||
persisted: []string{"b.example"},
|
||||
want: []string{"a.example", "b.example"},
|
||||
},
|
||||
{
|
||||
name: "Dedupes overlap",
|
||||
cli: []string{"a.example", "b.example"},
|
||||
persisted: []string{"b.example", "c.example"},
|
||||
want: []string{"a.example", "b.example", "c.example"},
|
||||
},
|
||||
{
|
||||
name: "Drops empty + whitespace",
|
||||
cli: []string{" ", "a.example", ""},
|
||||
persisted: []string{"", " b.example "},
|
||||
want: []string{"a.example", "b.example"},
|
||||
},
|
||||
{
|
||||
name: "Both empty",
|
||||
cli: nil,
|
||||
persisted: nil,
|
||||
want: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := mergeTLSExtraHosts(tc.cli, tc.persisted)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("len mismatch: got %v, want %v", got, tc.want)
|
||||
}
|
||||
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("index %d: got %q, want %q (full: %v vs %v)", i, got[i], tc.want[i], got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,15 @@ The `:443` indicator is only displayed when **AfterTouch's DNS interception is e
|
||||
|
||||
When AfterTouch's configured `--server-url` is `http://…`, the pre-flight short-circuits to an `ℹ️ :443 reachability check not applicable` info line. Speakers that were migrated to that HTTP URL never connect to `:443`, so the iptables / setcap / reverse-proxy work is only needed if you also expect unmigrated speakers to fall back to `streaming.bose.com:443` via DNS hijack. If that's not your situation, the iptables rules above are optional.
|
||||
|
||||
#### Adding extra hosts to the TLS certificate
|
||||
|
||||
If speakers reach AfterTouch via a hostname or IP that isn't already covered by the served certificate, the speaker rejects the TLS handshake (typical syslog: `CURLE_SSL_CACERT (60)`). Two paths to fix this:
|
||||
|
||||
* **One-click QuickFix on the Health tab.** The `speaker_marge_url` check detects the mismatch and offers an `Add <host> to TLS hosts` button. Clicking it appends the missing host to `settings.json` (`tls_extra_hosts`). A subsequent service restart regenerates the certificate.
|
||||
* **Settings tab → "TLS extra hosts" textarea.** Add one host per line and click Save. Same persistence path; restart required to apply. The textarea is pre-filled with the persisted list; the read-only "Currently covered by TLS cert" line below it shows the full effective SAN list (including the values from `--server-url`, `--https-server-url`, the system hostname, and any `--tls-extra-host` / `TLS_EXTRA_HOST` CLI/env entries).
|
||||
|
||||
CLI/env values still win over persisted ones, so an operator who pinned a host via systemd unit doesn't have to migrate it into `settings.json` — the merge in `applyPersistedSettings` deduplicates while preserving order.
|
||||
|
||||
If you intercept Bose hostnames **outside** AfterTouch (Pi-hole, router DNS rule, `/etc/hosts` on a gateway), the UI gate above will hide the indicator. The data is still in the `GET /setup/settings` JSON response (`https_443_localhost_reachable`, `https_443_lan_reachable`, `https_443_lan_host`, `https_443_not_applicable`, `https_443_reason`) if you want to inspect it directly, or you can briefly enable AfterTouch's DNS server to see the indicator render.
|
||||
|
||||
---
|
||||
|
||||
@@ -2346,6 +2346,14 @@ type Settings struct {
|
||||
// different host within a known-good private subnet.
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
|
||||
|
||||
// TLSExtraHosts is the persisted list of additional DNS names or IPs
|
||||
// to include in the TLS certificate SAN list. Merged with the
|
||||
// CLI/env --tls-extra-host values at startup (CLI/env wins; persisted
|
||||
// values are additive and deduplicated). Applying a change requires a
|
||||
// service restart so the TLS cert can be regenerated. Used by the
|
||||
// `speaker_marge_url` health check's QuickFix and the Settings tab UI.
|
||||
TLSExtraHosts []string `json:"tls_extra_hosts,omitempty"`
|
||||
|
||||
// TuneInStreamFormats overrides the comma-separated format list
|
||||
// AfterTouch sends to TuneIn's Tune.ashx (formats=…). Empty value
|
||||
// uses bmx.DefaultTuneInStreamFormats ("mp3,aac,ogg"), which
|
||||
|
||||
@@ -197,6 +197,8 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
"https_443_check_skipped": probe443.Skipped,
|
||||
"https_443_not_applicable": probe443.NotApplicable,
|
||||
"https_443_reason": probe443.Reason,
|
||||
"tls_extra_hosts": s.persistedTLSExtraHosts(),
|
||||
"tls_san_hosts": s.ExpectedHosts(),
|
||||
"https_443_localhost_reachable": probe443.Localhost.Reachable,
|
||||
"https_443_localhost_error": probe443.Localhost.Error,
|
||||
"https_443_lan_reachable": probe443.LAN.Reachable,
|
||||
@@ -245,6 +247,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
AmazonClientID string `json:"amazon_client_id"`
|
||||
AmazonClientSecret string `json:"amazon_client_secret"`
|
||||
AmazonRedirectURI string `json:"amazon_redirect_uri"`
|
||||
TLSExtraHosts *[]string `json:"tls_extra_hosts"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
@@ -324,6 +327,13 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
currentRecord := s.recordEnabled
|
||||
currentHTTPS := s.httpsServerURL
|
||||
|
||||
// Resolve TLS extra hosts: nil pointer means "field omitted, preserve existing";
|
||||
// non-nil (even empty) means "replace with this list".
|
||||
resolvedTLSExtraHosts := s.persistedTLSExtraHosts()
|
||||
if settings.TLSExtraHosts != nil {
|
||||
resolvedTLSExtraHosts = normaliseTLSExtraHosts(*settings.TLSExtraHosts)
|
||||
}
|
||||
|
||||
log.Printf("Saving updated settings to %s/settings.json", s.ds.DataDir)
|
||||
err = s.ds.SaveSettings(datastore.Settings{
|
||||
ServerURL: s.serverURL,
|
||||
@@ -344,6 +354,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
AmazonClientID: s.amazonClientID,
|
||||
AmazonClientSecret: s.amazonClientSecret,
|
||||
AmazonRedirectURI: s.amazonRedirectURI,
|
||||
TLSExtraHosts: resolvedTLSExtraHosts,
|
||||
})
|
||||
|
||||
dnsEnabled := s.dnsEnabled
|
||||
@@ -377,6 +388,28 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// normaliseTLSExtraHosts trims whitespace from each entry, drops empty
|
||||
// values, and deduplicates while preserving the first occurrence's
|
||||
// position. The settings endpoint applies this before persisting so the
|
||||
// stored list is always canonical.
|
||||
func normaliseTLSExtraHosts(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
seen := make(map[string]bool, len(in))
|
||||
|
||||
for _, h := range in {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" || seen[h] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[h] = true
|
||||
|
||||
out = append(out, h)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// HandleGetDeviceInfo returns live information for a device.
|
||||
func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/health"
|
||||
)
|
||||
|
||||
// addMargeHostToTLSFix is the FixFunc registered for the
|
||||
// (CheckIDSpeakerMargeURL, FixIDAddMargeHostToTLS) pair. It re-probes
|
||||
// the target device's <margeURL>, extracts the host portion, and
|
||||
// appends it to the persisted Settings.TLSExtraHosts. A subsequent
|
||||
// service restart picks up the change via the regular settings
|
||||
// merge path in applyPersistedSettings (cmd/soundtouch-service/main.go).
|
||||
//
|
||||
// The re-probe is deliberate: the persisted Settings only become
|
||||
// authoritative after the operator restarts AfterTouch, so reading
|
||||
// the margeURL fresh from the speaker avoids racing a stale finding
|
||||
// that was rendered before the speaker rebooted.
|
||||
//
|
||||
// Returns a success message that names the host and instructs the
|
||||
// operator to restart the service. Returns an error if the device
|
||||
// can't be located, the probe fails, or the marge URL is empty /
|
||||
// unparseable.
|
||||
func (s *Server) addMargeHostToTLSFix(target health.Target) (string, error) {
|
||||
if target.Device == "" {
|
||||
return "", fmt.Errorf("device is required")
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(target.Device)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("locate device %s: %w", target.Device, err)
|
||||
}
|
||||
|
||||
probeURL := fmt.Sprintf("http://%s:8090/info", deviceIP)
|
||||
|
||||
margeHost, err := fetchMargeHostFromSpeaker(probeURL, 2*time.Second)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if margeHost == "" {
|
||||
return "", fmt.Errorf("speaker %s has no <margeURL>; nothing to add", target.Device)
|
||||
}
|
||||
|
||||
persisted, err := s.ds.GetSettings()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load settings: %w", err)
|
||||
}
|
||||
|
||||
for _, existing := range persisted.TLSExtraHosts {
|
||||
if strings.EqualFold(strings.TrimSpace(existing), margeHost) {
|
||||
return fmt.Sprintf("%s is already in the persisted TLS hosts. Restart AfterTouch to regenerate the TLS certificate if you haven't already.", margeHost), nil
|
||||
}
|
||||
}
|
||||
|
||||
persisted.TLSExtraHosts = append(persisted.TLSExtraHosts, margeHost)
|
||||
|
||||
if err := s.ds.SaveSettings(persisted); err != nil {
|
||||
return "", fmt.Errorf("save settings: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Added %s to persisted TLS hosts (tls_extra_hosts). Restart AfterTouch for the TLS certificate to be regenerated and include this host in its SAN list.", margeHost), nil
|
||||
}
|
||||
|
||||
// fetchMargeHostFromSpeaker probes the given speaker /info URL and
|
||||
// returns the host portion of the <margeURL> XML element. Returns an
|
||||
// empty string with no error when the speaker responds but doesn't
|
||||
// carry a margeURL. Returns a non-nil error when the probe itself
|
||||
// fails or the response can't be parsed.
|
||||
func fetchMargeHostFromSpeaker(probeURL string, timeout time.Duration) (string, error) {
|
||||
res := health.ProbeGet(context.Background(), probeURL, timeout)
|
||||
if !res.Reachable {
|
||||
return "", fmt.Errorf("speaker probe failed: %s", res.Err)
|
||||
}
|
||||
|
||||
if res.Status != 200 {
|
||||
return "", fmt.Errorf("speaker /info returned HTTP %d", res.Status)
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
MargeURL string `xml:"margeURL"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
|
||||
return "", fmt.Errorf("parse /info: %w", err)
|
||||
}
|
||||
|
||||
if parsed.MargeURL == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(parsed.MargeURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse margeURL %q: %w", parsed.MargeURL, err)
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
host = strings.TrimSpace(parsed.MargeURL)
|
||||
}
|
||||
|
||||
return host, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func stubInfoForFix(t *testing.T, margeURL string) string {
|
||||
t.Helper()
|
||||
|
||||
body := `<?xml version="1.0" encoding="UTF-8" ?><info deviceID="DEVICEID01"><name>Test</name><margeAccountUUID>1000001</margeAccountUUID><margeURL>` + margeURL + `</margeURL></info>`
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/info" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
return srv.URL + "/info"
|
||||
}
|
||||
|
||||
func TestFetchMargeHostFromSpeaker_ReturnsHostOnly(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
margeURL string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "HTTPS with port",
|
||||
margeURL: "https://aftertouch.example:8443/",
|
||||
want: "aftertouch.example",
|
||||
},
|
||||
{
|
||||
name: "HTTP IP with port",
|
||||
margeURL: "http://192.0.2.10:8000/",
|
||||
want: "192.0.2.10",
|
||||
},
|
||||
{
|
||||
name: "Bare host fallback",
|
||||
margeURL: "aftertouch.example",
|
||||
want: "aftertouch.example",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
probeURL := stubInfoForFix(t, tc.margeURL)
|
||||
|
||||
got, err := fetchMargeHostFromSpeaker(probeURL, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMargeHostFromSpeaker_EmptyMargeURLReturnsEmpty(t *testing.T) {
|
||||
probeURL := stubInfoForFix(t, "")
|
||||
|
||||
got, err := fetchMargeHostFromSpeaker(probeURL, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got != "" {
|
||||
t.Errorf("expected empty host for empty margeURL, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMargeHostFromSpeaker_UnreachableReturnsError(t *testing.T) {
|
||||
// Point at a closed port; the probe should fail without panicking.
|
||||
_, err := fetchMargeHostFromSpeaker("http://127.0.0.1:1/info", 200*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Errorf("expected error for unreachable speaker, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "probe failed") {
|
||||
t.Errorf("expected 'probe failed' in error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,15 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
|
||||
health.FixIDCompleteSpeakerPairing,
|
||||
s.completeSpeakerPairingFix,
|
||||
)
|
||||
|
||||
// QuickFix executor for the speaker_marge_url mismatch finding.
|
||||
// Adds the speaker's actual margeURL host to settings.TLSExtraHosts
|
||||
// so a subsequent restart picks it up via applyPersistedSettings.
|
||||
s.healthRegistry.RegisterFix(
|
||||
health.CheckIDSpeakerMargeURL,
|
||||
health.FixIDAddMargeHostToTLS,
|
||||
s.addMargeHostToTLSFix,
|
||||
)
|
||||
health.RegisterDNSSanityCheck(
|
||||
s.healthRegistry,
|
||||
s.GetDNSRunning,
|
||||
@@ -188,6 +197,25 @@ func (s *Server) ExpectedHosts() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// persistedTLSExtraHosts returns the slice of TLS extra hosts that
|
||||
// live in settings.json. Used by HandleGetSettings to render the
|
||||
// "edit list" UI separately from the full effective SAN list
|
||||
// (ExpectedHosts also contains serverURL host, httpsServerURL host,
|
||||
// hostname, and CLI/env-pinned extras). Returns an empty slice if
|
||||
// the settings file is missing or unreadable — the caller should
|
||||
// treat that the same as "operator hasn't added anything yet".
|
||||
func (s *Server) persistedTLSExtraHosts() []string {
|
||||
persisted, err := s.ds.GetSettings()
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
out := make([]string, len(persisted.TLSExtraHosts))
|
||||
copy(out, persisted.TLSExtraHosts)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// ownCACertPath returns the on-disk path of AfterTouch's own CA
|
||||
// cert (PEM). Empty string when the certmanager isn't wired in.
|
||||
// Used by the Health-tab CA-expiry check to render an accurate
|
||||
|
||||
@@ -165,6 +165,36 @@
|
||||
<div id="target-domain-resolved" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div>
|
||||
<div id="https-443-status" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px">
|
||||
<strong>TLS extra hosts:</strong>
|
||||
<span class="info-toggle" onclick="toggleInfo('tls-extra-hosts-info')">ⓘ</span>
|
||||
<div id="tls-extra-hosts-info" class="info-details">
|
||||
Additional DNS names or IPs the served TLS certificate
|
||||
should cover. Speakers that talk to AfterTouch via a
|
||||
hostname or IP not already in the cert's SAN list
|
||||
reject the TLS handshake — typically symptom:
|
||||
<code>CURLE_SSL_CACERT (60)</code> in the speaker
|
||||
syslog. The
|
||||
<code>speaker_marge_url</code> health check on the
|
||||
Health tab detects this and offers a one-click
|
||||
QuickFix that appends the missing host here.<br/>
|
||||
<strong>Applying changes requires a service restart.</strong>
|
||||
The TLS certificate is regenerated at startup from
|
||||
the merged list of <code>--server-url</code> host,
|
||||
<code>--https-server-url</code> host, the system
|
||||
hostname, any <code>--tls-extra-host</code> /
|
||||
<code>TLS_EXTRA_HOST</code> CLI/env values, and the
|
||||
hosts persisted below.
|
||||
</div>
|
||||
<div style="margin-top: 5px">
|
||||
<textarea
|
||||
id="tls-extra-hosts"
|
||||
placeholder="One host per line, e.g. 192.0.2.10 or aftertouch.lan"
|
||||
style="width: 360px; height: 84px; font-family: monospace; font-size: 0.9em;"
|
||||
></textarea>
|
||||
</div>
|
||||
<div id="tls-san-effective" style="font-size: 0.8em; color: #666; margin-top: 4px;"></div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px">
|
||||
<strong>Device Discovery:</strong>
|
||||
<div style="margin-top: 5px">
|
||||
|
||||
@@ -283,6 +283,18 @@ async function fetchSettings() {
|
||||
document.getElementById("internal-paths").value = settings.internal_paths.join("\n");
|
||||
}
|
||||
|
||||
if (Array.isArray(settings.tls_extra_hosts)) {
|
||||
document.getElementById("tls-extra-hosts").value = settings.tls_extra_hosts.join("\n");
|
||||
}
|
||||
const effective = document.getElementById("tls-san-effective");
|
||||
if (effective) {
|
||||
if (Array.isArray(settings.tls_san_hosts) && settings.tls_san_hosts.length) {
|
||||
effective.innerText = "Currently covered by TLS cert: " + settings.tls_san_hosts.join(", ");
|
||||
} else {
|
||||
effective.innerText = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Spotify credential fields
|
||||
if (settings.spotify_client_id !== undefined) {
|
||||
document.getElementById("spotify-client-id").value = settings.spotify_client_id || "";
|
||||
@@ -376,6 +388,11 @@ async function updateSettings() {
|
||||
amazon_client_id: document.getElementById("amazon-client-id").value,
|
||||
amazon_client_secret: document.getElementById("amazon-client-secret").value,
|
||||
amazon_redirect_uri: document.getElementById("amazon-redirect-uri").value,
|
||||
tls_extra_hosts: document
|
||||
.getElementById("tls-extra-hosts")
|
||||
.value.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== ""),
|
||||
};
|
||||
const status = document.getElementById("settings-status");
|
||||
status.innerText = "Saving...";
|
||||
|
||||
@@ -11,6 +11,15 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// FixIDAddMargeHostToTLS is the QuickFix that re-probes the speaker
|
||||
// at the target device's known IP, extracts the host portion of its
|
||||
// <margeURL>, and appends it to the persisted TLSExtraHosts in
|
||||
// settings.json. A service restart is then required for the TLS cert
|
||||
// to be regenerated. The fix lives in the handlers package because
|
||||
// it needs the datastore writer; the constant lives here so check
|
||||
// and fix share the same identifier.
|
||||
const FixIDAddMargeHostToTLS = "add_marge_host_to_tls"
|
||||
|
||||
// CheckIDSpeakerMargeURL is the registry id of the Marge-URL
|
||||
// consistency check.
|
||||
const CheckIDSpeakerMargeURL = "speaker_marge_url"
|
||||
@@ -112,11 +121,16 @@ func assessMargeURLForDeviceWithURL(account, deviceID, probeURL string, expected
|
||||
parsed.MargeURL,
|
||||
),
|
||||
Details: fmt.Sprintf(
|
||||
"Configured hosts: %s. If the speaker should reach this service via %q, restart with `--tls-extra-host=%s` so the served TLS cert covers it. Otherwise, re-migrate the speaker to the correct URL.",
|
||||
"Configured hosts: %s. If the speaker should reach this service via %q, click the QuickFix below (or restart with `--tls-extra-host=%s`) so the served TLS cert covers it. Otherwise, re-migrate the speaker to the correct URL.",
|
||||
joinHosts(expected), margeHost, margeHost,
|
||||
),
|
||||
QuickFixes: []QuickFix{{
|
||||
ID: FixIDAddMargeHostToTLS,
|
||||
Label: fmt.Sprintf("Add %s to TLS hosts", margeHost),
|
||||
Confirm: fmt.Sprintf("This will append %s to settings.json (tls_extra_hosts) and persist it. A service restart is required afterwards for the TLS certificate to be regenerated.", margeHost),
|
||||
}},
|
||||
ManualCommands: []ManualCommand{{
|
||||
Label: "Add the speaker's expected hostname to AfterTouch's TLS cert:",
|
||||
Label: "Or set via CLI/env and restart:",
|
||||
Command: fmt.Sprintf("soundtouch-service --tls-extra-host=%s …", margeHost),
|
||||
Hint: "Append to your existing service command-line / env (TLS_EXTRA_HOST). Requires a restart.",
|
||||
}},
|
||||
|
||||
@@ -64,6 +64,18 @@ func TestMargeURL_FlagsMismatch(t *testing.T) {
|
||||
if !strings.Contains(cmd, "tls-extra-host=other-host.example") {
|
||||
t.Errorf("expected --tls-extra-host suggestion, got %q", cmd)
|
||||
}
|
||||
|
||||
if len(got[0].QuickFixes) != 1 || got[0].QuickFixes[0].ID != FixIDAddMargeHostToTLS {
|
||||
t.Fatalf("expected QuickFix with ID=%s, got %+v", FixIDAddMargeHostToTLS, got[0].QuickFixes)
|
||||
}
|
||||
|
||||
if !strings.Contains(got[0].QuickFixes[0].Label, "other-host.example") {
|
||||
t.Errorf("expected QuickFix label to name the missing host, got %q", got[0].QuickFixes[0].Label)
|
||||
}
|
||||
|
||||
if got[0].QuickFixes[0].Confirm == "" {
|
||||
t.Errorf("expected QuickFix to carry a Confirm message (operator needs to know a restart is required)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeURL_SkipsWhenMargeURLEmpty(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user