mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
co-authored by
Claude Opus 4.7
parent
255dd655b5
commit
dee5a0146c
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user