feat(health): check speaker <margeURL> against configured hosts

For each device, probe /info and extract the <margeURL> the
speaker is configured to talk to. Compare the hostname against
the service's expected-hosts list (serverURL host +
httpsServerURL host + --tls-extra-host values).

When the speaker is pointed at a host AfterTouch doesn't claim,
emit a warning with two pieces of context:
  - the actual <margeURL>, so the operator sees the drift
  - a copyable `soundtouch-service --tls-extra-host=<host>`
    suggestion, which is the right fix when the speaker should
    keep talking to AfterTouch via the unexpected hostname (the
    other fix is re-migration, which is mentioned in the details).

Reachability / parse failures are intentionally silent here —
speaker_info_reachable already covers those, no need to double-warn.

Required plumbing: Server.SetExpectedHosts so main.go can pass
config.domains in, plus an ExpectedHosts() getter the closure-form
registration reads at run time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-19 23:20:40 +02:00
co-authored by Claude Opus 4.7
parent 255dd655b5
commit dee5a0146c
4 changed files with 331 additions and 0 deletions
+1
View File
@@ -435,6 +435,7 @@ func main() {
sm.GetDNSRunning = server.GetDNSRunning
server.SetLogBuffer(logBuf)
server.SetHTTPServerURL(config.httpsServerURL)
server.SetExpectedHosts(config.domains)
server.SetVersionInfo(version, commit, date, repoURL)
server.SetDiscoverySettings(config.discoveryInterval, config.discoveryEnabled)
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
+27
View File
@@ -66,6 +66,7 @@ type Server struct {
peerObserver *peerObserver
healthRegistry *health.Registry
logBuf *logbuf.Buffer
expectedHosts []string
}
// RequestSnapshot represents an immutable snapshot of an HTTP request.
@@ -107,10 +108,36 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
health.RegisterSourcesXMLPresent(s.healthRegistry, ds)
health.RegisterSpeakerInfoReachable(s.healthRegistry, ds)
health.RegisterSourcesXMLDiff(s.healthRegistry, ds)
health.RegisterSpeakerMargeURLCheck(s.healthRegistry, ds, s.ExpectedHosts)
return s
}
// SetExpectedHosts records the hostnames the service considers its
// own (serverURL host + httpsServerURL host + --tls-extra-host
// values). The Health tab's Marge-URL check reads this list at
// run time to decide whether a speaker's <margeURL> points at us.
func (s *Server) SetExpectedHosts(hosts []string) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]string, len(hosts))
copy(out, hosts)
s.expectedHosts = out
}
// ExpectedHosts returns a copy of the recorded expected-hosts list.
func (s *Server) ExpectedHosts() []string {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]string, len(s.expectedHosts))
copy(out, s.expectedHosts)
return out
}
// TrustedRealIPMiddleware returns a chi middleware that rewrites
// r.RemoteAddr from X-Real-IP / X-Forwarded-For / True-Client-IP, but only
// when the immediate TCP peer is in the configured trusted-proxy list.
+180
View File
@@ -0,0 +1,180 @@
package health
import (
"context"
"encoding/xml"
"fmt"
"net/url"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// CheckIDSpeakerMargeURL is the registry id of the Marge-URL
// consistency check.
const CheckIDSpeakerMargeURL = "speaker_marge_url"
// RegisterSpeakerMargeURLCheck registers the speaker_marge_url
// check. For each device it probes /info, extracts <margeURL>, and
// compares the hostname against the service's expected-hosts list
// (serverURL host + httpsServerURL host + --tls-extra-host values).
// If they don't match, the speaker is talking to a different
// endpoint than this service thinks it serves — usually a sign
// that AfterTouch was reconfigured after the speaker was
// migrated, or that the speaker is pointed at the wrong DNS name.
//
// expectedHostsFn is a closure so the check picks up config
// changes without re-registration (today these change only at
// restart, but the closure costs nothing).
func RegisterSpeakerMargeURLCheck(r *Registry, ds *datastore.DataStore, expectedHostsFn func() []string) {
r.Register(Check{
ID: CheckIDSpeakerMargeURL,
Title: "Speaker <margeURL> matches AfterTouch's configured hosts",
Run: func() []Finding {
return runSpeakerMargeURLCheck(ds, expectedHostsFn)
},
})
}
func runSpeakerMargeURLCheck(ds *datastore.DataStore, expectedHostsFn func() []string) []Finding {
if ds == nil {
return nil
}
devices, err := ds.ListAllDevices()
if err != nil {
return []Finding{{
Severity: SeverityError,
Message: "Could not enumerate devices: " + err.Error(),
}}
}
expected := normaliseHosts(expectedHostsFn())
var findings []Finding
for i := range devices {
dev := &devices[i]
if dev.IPAddress == "" || dev.DeviceID == "" {
continue
}
findings = append(findings, assessMargeURLForDevice(dev.AccountID, dev.DeviceID, dev.IPAddress, expected)...)
}
return findings
}
func assessMargeURLForDevice(account, deviceID, ipAddress string, expected map[string]bool) []Finding {
probeURL := fmt.Sprintf("http://%s:8090/info", ipAddress)
return assessMargeURLForDeviceWithURL(account, deviceID, probeURL, expected)
}
// assessMargeURLForDeviceWithURL is the same but takes the URL
// directly. Used by tests bound to an httptest.Server.
func assessMargeURLForDeviceWithURL(account, deviceID, probeURL string, expected map[string]bool) []Finding {
target := Target{Account: account, Device: deviceID}
res := ProbeGet(context.Background(), probeURL, 2*time.Second)
if !res.Reachable || res.Status != 200 {
// speaker_info_reachable already covers these cases.
return nil
}
var parsed speakerInfoXML
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
return nil
}
if parsed.MargeURL == "" {
return nil
}
margeHost := hostFromURL(parsed.MargeURL)
if margeHost == "" {
return []Finding{{
Severity: SeverityWarning,
Target: target,
Message: fmt.Sprintf("Speaker reports an unparseable <margeURL>: %q", parsed.MargeURL),
}}
}
if expected[margeHost] {
return nil
}
return []Finding{{
Severity: SeverityWarning,
Target: target,
Message: fmt.Sprintf(
"Speaker is pointed at %s, which isn't in the service's configured hosts.",
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.",
joinHosts(expected), margeHost, margeHost,
),
ManualCommands: []ManualCommand{{
Label: "Add the speaker's expected hostname to AfterTouch's TLS cert:",
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.",
}},
}}
}
func normaliseHosts(in []string) map[string]bool {
out := make(map[string]bool, len(in))
for _, h := range in {
h = strings.TrimSpace(strings.ToLower(h))
if h == "" {
continue
}
// Accept either bare host or URL-style input — be lenient
// since the registration call site may evolve.
if hostOnly := hostFromURL(h); hostOnly != "" {
out[hostOnly] = true
} else {
out[h] = true
}
}
return out
}
func hostFromURL(raw string) string {
if raw == "" {
return ""
}
if !strings.Contains(raw, "://") {
// Treat as a bare host. Strip an optional :port suffix.
if i := strings.IndexByte(raw, ':'); i >= 0 {
return strings.ToLower(strings.TrimSpace(raw[:i]))
}
return strings.ToLower(strings.TrimSpace(raw))
}
u, err := url.Parse(raw)
if err != nil {
return ""
}
return strings.ToLower(u.Hostname())
}
func joinHosts(m map[string]bool) string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
if len(keys) == 0 {
return "(none configured)"
}
return strings.Join(keys, ", ")
}
+123
View File
@@ -0,0 +1,123 @@
package health
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func stubInfoServer(t *testing.T, margeURL string) string {
t.Helper()
body := `<?xml version="1.0" encoding="UTF-8"?>
<info deviceID="DEVICEID01">
<name>TestSpeaker</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)
u, _ := url.Parse(srv.URL)
return "http://" + u.Host + "/info"
}
func TestMargeURL_NoFindingsWhenMatched(t *testing.T) {
probeURL := stubInfoServer(t, "https://aftertouch.local/")
expected := normaliseHosts([]string{"aftertouch.local", "192.0.2.10"})
got := assessMargeURLForDeviceWithURL("1000001", "DEVICEID01", probeURL, expected)
if len(got) != 0 {
t.Errorf("expected no findings when host matches, got %+v", got)
}
}
func TestMargeURL_FlagsMismatch(t *testing.T) {
probeURL := stubInfoServer(t, "https://other-host.example/")
expected := normaliseHosts([]string{"aftertouch.local"})
got := assessMargeURLForDeviceWithURL("1000001", "DEVICEID01", probeURL, expected)
if len(got) != 1 || got[0].Severity != SeverityWarning {
t.Fatalf("expected one warning, got %+v", got)
}
if !strings.Contains(got[0].Message, "other-host.example") {
t.Errorf("expected mismatched host in message, got %q", got[0].Message)
}
if len(got[0].ManualCommands) != 1 {
t.Fatalf("expected a manual command, got %+v", got[0].ManualCommands)
}
cmd := got[0].ManualCommands[0].Command
if !strings.Contains(cmd, "tls-extra-host=other-host.example") {
t.Errorf("expected --tls-extra-host suggestion, got %q", cmd)
}
}
func TestMargeURL_SkipsWhenMargeURLEmpty(t *testing.T) {
probeURL := stubInfoServer(t, "")
expected := normaliseHosts([]string{"aftertouch.local"})
got := assessMargeURLForDeviceWithURL("1000001", "DEVICEID01", probeURL, expected)
if len(got) != 0 {
t.Errorf("expected no findings for empty margeURL, got %+v", got)
}
}
func TestMargeURL_SkipsWhenSpeakerUnreachable(t *testing.T) {
expected := normaliseHosts([]string{"aftertouch.local"})
// speaker_info_reachable already covers the unreachable case,
// so this check should stay silent.
got := assessMargeURLForDeviceWithURL("1000001", "DEVICEID01", "http://127.0.0.1:1/info", expected)
if len(got) != 0 {
t.Errorf("expected no findings when speaker unreachable, got %+v", got)
}
}
func TestHostFromURL(t *testing.T) {
cases := []struct {
in, want string
}{
{"https://example.com/", "example.com"},
{"https://Example.COM:8443/", "example.com"},
{"http://192.0.2.10/", "192.0.2.10"},
{"example.com", "example.com"},
{"example.com:8443", "example.com"},
{"", ""},
{"://broken", ""},
}
for _, c := range cases {
if got := hostFromURL(c.in); got != c.want {
t.Errorf("hostFromURL(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestNormaliseHosts_DedupsAndLowercases(t *testing.T) {
out := normaliseHosts([]string{"AFTERTOUCH.local", "aftertouch.local", "https://example.com/", " ", ""})
if !out["aftertouch.local"] {
t.Errorf("expected aftertouch.local")
}
if !out["example.com"] {
t.Errorf("expected example.com")
}
if len(out) != 2 {
t.Errorf("expected 2 unique hosts, got %d", len(out))
}
}