From 3a0b30bc3355eacf8b5d5129b652a924cb7068c2 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Fri, 22 May 2026 20:12:09 +0200 Subject: [PATCH] feat(tls): persist TLSExtraHosts + Settings UI + speaker_marge_url QuickFix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- cmd/soundtouch-service/main.go | 35 ++++++ cmd/soundtouch-service/main_test.go | 61 ++++++++++ docs/guides/HTTPS-SETUP.md | 9 ++ pkg/service/datastore/datastore.go | 8 ++ pkg/service/handlers/handlers_setup.go | 33 ++++++ .../handlers/handlers_tls_hosts_fix.go | 109 ++++++++++++++++++ .../handlers/handlers_tls_hosts_fix_test.go | 92 +++++++++++++++ pkg/service/handlers/server.go | 28 +++++ pkg/service/handlers/web/index.html | 30 +++++ pkg/service/handlers/web/js/script.js | 17 +++ pkg/service/health/checks_marge_url.go | 18 ++- pkg/service/health/checks_marge_url_test.go | 12 ++ 12 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 pkg/service/handlers/handlers_tls_hosts_fix.go create mode 100644 pkg/service/handlers/handlers_tls_hosts_fix_test.go diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 4744f68..32c68eb 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -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) { diff --git a/cmd/soundtouch-service/main_test.go b/cmd/soundtouch-service/main_test.go index 2039ee8..8fa7d63 100644 --- a/cmd/soundtouch-service/main_test.go +++ b/cmd/soundtouch-service/main_test.go @@ -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) + } + } + }) + } +} diff --git a/docs/guides/HTTPS-SETUP.md b/docs/guides/HTTPS-SETUP.md index ef3595c..1ec3854 100644 --- a/docs/guides/HTTPS-SETUP.md +++ b/docs/guides/HTTPS-SETUP.md @@ -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 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. --- diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index cffef90..3b501cb 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -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 diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index b7bc9b6..2d02ae6 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -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") diff --git a/pkg/service/handlers/handlers_tls_hosts_fix.go b/pkg/service/handlers/handlers_tls_hosts_fix.go new file mode 100644 index 0000000..13036c8 --- /dev/null +++ b/pkg/service/handlers/handlers_tls_hosts_fix.go @@ -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 , 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 ; 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 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 +} diff --git a/pkg/service/handlers/handlers_tls_hosts_fix_test.go b/pkg/service/handlers/handlers_tls_hosts_fix_test.go new file mode 100644 index 0000000..58a1bd5 --- /dev/null +++ b/pkg/service/handlers/handlers_tls_hosts_fix_test.go @@ -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 := `Test1000001` + margeURL + `` + + 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()) + } +} diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 9a21024..635c0b1 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -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 diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index d2bef36..eae7b2e 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -165,6 +165,36 @@
+
+ TLS extra hosts: + +
+ 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: + CURLE_SSL_CACERT (60) in the speaker + syslog. The + speaker_marge_url health check on the + Health tab detects this and offers a one-click + QuickFix that appends the missing host here.
+ Applying changes requires a service restart. + The TLS certificate is regenerated at startup from + the merged list of --server-url host, + --https-server-url host, the system + hostname, any --tls-extra-host / + TLS_EXTRA_HOST CLI/env values, and the + hosts persisted below. +
+
+ +
+
+
Device Discovery:
diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js index 792e81b..cf8afb1 100644 --- a/pkg/service/handlers/web/js/script.js +++ b/pkg/service/handlers/web/js/script.js @@ -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..."; diff --git a/pkg/service/health/checks_marge_url.go b/pkg/service/health/checks_marge_url.go index 53cbc27..fc1f7e2 100644 --- a/pkg/service/health/checks_marge_url.go +++ b/pkg/service/health/checks_marge_url.go @@ -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 +// , 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.", }}, diff --git a/pkg/service/health/checks_marge_url_test.go b/pkg/service/health/checks_marge_url_test.go index 65e1097..e34c49e 100644 --- a/pkg/service/health/checks_marge_url_test.go +++ b/pkg/service/health/checks_marge_url_test.go @@ -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) {