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:
Tobias Gesellchen
2026-05-22 20:32:44 +02:00
co-authored by Claude Sonnet 4.6
parent 377fa9ceda
commit 3a0b30bc33
12 changed files with 450 additions and 2 deletions
+35
View File
@@ -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) {
+61
View File
@@ -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)
}
}
})
}
}
+9
View File
@@ -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.
---
+8
View File
@@ -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
+33
View File
@@ -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())
}
}
+28
View File
@@ -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
+30
View File
@@ -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">
+17
View File
@@ -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...";
+16 -2
View File
@@ -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) {