diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 045fbcd..eafb8a4 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -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) diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 6be3a38..3f7d3b4 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -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 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. diff --git a/pkg/service/health/checks_marge_url.go b/pkg/service/health/checks_marge_url.go new file mode 100644 index 0000000..53cbc27 --- /dev/null +++ b/pkg/service/health/checks_marge_url.go @@ -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 , 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 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 : %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, ", ") +} diff --git a/pkg/service/health/checks_marge_url_test.go b/pkg/service/health/checks_marge_url_test.go new file mode 100644 index 0000000..65e1097 --- /dev/null +++ b/pkg/service/health/checks_marge_url_test.go @@ -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 := ` + + TestSpeaker + 1000001 + ` + 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) + + 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)) + } +}