From 417c0223dde50e8a8c78444d947934957806bf10 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Wed, 27 May 2026 00:09:51 +0200 Subject: [PATCH] feat(health): add server_url self-reachability check + log actual listen port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The most common misconfiguration on install-on-speaker setups is an HTTP server URL that omits the port (e.g. http://192.0.2.1 instead of http://192.0.2.1:8000). Port 80 is occupied by the Bose firmware's PtsServer, so AfterTouch binds its default port 8000 — but the margeURL pushed to speakers still resolves to port 80 and hits PtsServer instead of AfterTouch. Marge calls are silently dropped, sources are never registered, and TuneIn playback fails with error 1005 (UNKNOWN_SOURCE_ERROR). See issue #319. Changes: - pkg/service/health/checks_server_url.go — new health check (server_url_reachable) that probes GET {serverURL}/setup/version from inside the service; emits SeverityWarning with remediation steps when the endpoint is not reachable or returns non-200. - pkg/service/handlers/server.go — register the new check in NewServer. - cmd/soundtouch-service/main.go — replace http.ListenAndServe with an explicit net.Listen so the true effective port is logged before TLS starts. Both HTTP and HTTPS log lines now show the listener's actual bound address alongside the configured server URL: Go service listening on 0.0.0.0:8000 (server URL: http://192.0.2.1) Previously only the server URL was logged, creating the false impression that AfterTouch had bound that URL's implicit port. Co-Authored-By: Claude Sonnet 4.6 --- cmd/soundtouch-service/main.go | 18 +++- pkg/service/handlers/server.go | 4 + pkg/service/health/checks_server_url.go | 88 ++++++++++++++++++++ pkg/service/health/checks_server_url_test.go | 66 +++++++++++++++ 4 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 pkg/service/health/checks_server_url.go create mode 100644 pkg/service/health/checks_server_url_test.go diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 5b69b33..bac08be 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -518,7 +518,16 @@ func main() { r := setupRouter(server, stockholmHandler) - log.Printf("Go service starting on %s", sanitizeLog(config.serverURL)) + // Bind the listener before logging so we print the true + // effective port (handles :0 and catches "address already + // in use" before the TLS goroutine launches). + ln, err := net.Listen("tcp", config.addr) + if err != nil { + return fmt.Errorf("failed to listen on %s: %w", config.addr, err) + } + + log.Printf("Go service listening on %s (server URL: %s)", + ln.Addr().String(), sanitizeLog(config.serverURL)) // TLS cert generation can be slow on constrained hardware; run it in the // background so the HTTP server is available immediately. @@ -536,7 +545,7 @@ func main() { runHTTPSPreflight(config.httpsServerURL, config.serverURL, config.dnsEnabled, server.ResolveServerURLIPForPreflight) }() - return http.ListenAndServe(config.addr, r) + return http.Serve(ln, r) }, Commands: []*cli.Command{ { @@ -1306,8 +1315,6 @@ func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, h ErrorLog: log.Default(), // Ensure error logging is enabled } - log.Printf("Go service starting HTTPS on %s", sanitizeLog(httpsServerURL)) - go func() { listener, err := net.Listen("tcp", httpsAddr) if err != nil { @@ -1315,6 +1322,9 @@ func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, h return } + log.Printf("Go service listening HTTPS on %s (server URL: %s)", + listener.Addr().String(), sanitizeLog(httpsServerURL)) + tlsListener := tls.NewListener(listener, tlsConfig) // Wrap listener to log connection attempts diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 35459fb..b2205bf 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -135,6 +135,10 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red health.RegisterRefreshSourcesCheck(s.healthRegistry, ds) health.RegisterStaleInternetRadioCheck(s.healthRegistry, ds) health.RegisterDefaultAccountNonBoseDevicesCheck(s.healthRegistry, ds) + health.RegisterServerURLReachableCheck(s.healthRegistry, func() string { + serverURL, _ := s.GetSettings() + return serverURL + }) health.RegisterOAuthTargetReachableCheck( s.healthRegistry, func() string { diff --git a/pkg/service/health/checks_server_url.go b/pkg/service/health/checks_server_url.go new file mode 100644 index 0000000..207efd6 --- /dev/null +++ b/pkg/service/health/checks_server_url.go @@ -0,0 +1,88 @@ +package health + +import ( + "context" + "fmt" + "strings" + "time" +) + +// CheckIDServerURLReachable is the registry id of the server-URL +// self-reachability check. +const CheckIDServerURLReachable = "server_url_reachable" + +// RegisterServerURLReachableCheck registers the server_url_reachable +// check. It probes GET {serverURL}/setup/version from inside the +// service. If the request fails or returns a non-200 status, the +// configured server URL doesn't route back to AfterTouch — speakers +// pointing their margeURL at that address will receive errors instead +// of marge responses. +// +// The most common cause is a missing port number in the server URL: +// e.g. "http://192.0.2.1" implies port 80, but if another process +// (such as the Bose firmware's PtsServer) already occupies port 80, +// AfterTouch runs on its default port (8000) instead — and all marge +// calls from speakers silently hit the wrong process. +// +// getServerURL is a closure so the check picks up config changes +// without re-registration. +func RegisterServerURLReachableCheck(r *Registry, getServerURL func() string) { + r.Register(Check{ + ID: CheckIDServerURLReachable, + Title: "Service is reachable at configured server URL", + Run: func() []Finding { + return runServerURLReachableCheck(getServerURL()) + }, + }) +} + +func runServerURLReachableCheck(serverURL string) []Finding { + if strings.TrimSpace(serverURL) == "" { + return nil + } + + probeURL := strings.TrimRight(serverURL, "/") + "/setup/version" + res := ProbeGet(context.Background(), probeURL, 2*time.Second) + + if res.Reachable && res.Status == 200 { + return nil + } + + errDetail := res.Err + if errDetail == "" && res.Status != 0 { + errDetail = fmt.Sprintf("HTTP %d", res.Status) + } + + details := fmt.Sprintf( + "Probe: GET %s → %s. "+ + "The most common cause is a missing port in the server URL. "+ + "Example: \"http://192.0.2.1\" implies port 80; if another process "+ + "(e.g. the Bose firmware's PtsServer) occupies port 80, AfterTouch runs on its "+ + "default port 8000 instead. Fix: add the explicit port, e.g. \"http://192.0.2.1:8000\", "+ + "then restart the service and re-apply the migration for each speaker to push the "+ + "updated margeURL.", + probeURL, errDetail, + ) + + return []Finding{{ + Severity: SeverityWarning, + Message: fmt.Sprintf( + "Configured server URL %q is not reachable from inside the service (probe: %s). "+ + "Marge calls from speakers will fail — check that the URL and port match "+ + "the service's actual listening port.", + serverURL, errDetail, + ), + Details: details, + ManualCommands: []ManualCommand{ + { + Label: "Check what port AfterTouch is listening on:", + Command: "ss -tlnp | grep soundtouch", + Hint: "Compare the listening port with the port implied by the configured server URL.", + }, + { + Label: "Or set the correct URL via the web UI:", + Command: "Settings tab → Server URL (Target Domain) → add explicit port (e.g. :8000) → Save → restart", + }, + }, + }} +} diff --git a/pkg/service/health/checks_server_url_test.go b/pkg/service/health/checks_server_url_test.go new file mode 100644 index 0000000..73ada5f --- /dev/null +++ b/pkg/service/health/checks_server_url_test.go @@ -0,0 +1,66 @@ +package health + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestServerURLReachableCheck_PassesWhenVersionEndpointReturns200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/setup/version" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"version":"test"}`)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + got := runServerURLReachableCheck(srv.URL) + if len(got) != 0 { + t.Errorf("expected no findings when /setup/version returns 200, got %+v", got) + } +} + +func TestServerURLReachableCheck_WarnsWhenVersionEndpointReturns404(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer srv.Close() + + got := runServerURLReachableCheck(srv.URL) + if len(got) != 1 { + t.Fatalf("expected one finding when /setup/version returns 404, got %+v", got) + } + + if got[0].Severity != SeverityWarning { + t.Errorf("expected SeverityWarning, got %v", got[0].Severity) + } + + if !strings.Contains(got[0].Message, srv.URL) { + t.Errorf("expected server URL in message, got %q", got[0].Message) + } +} + +func TestServerURLReachableCheck_WarnsWhenServerUnreachable(t *testing.T) { + // Use a port that is almost certainly not listening. + got := runServerURLReachableCheck("http://127.0.0.1:19999") + if len(got) != 1 { + t.Fatalf("expected one finding for unreachable server, got %+v", got) + } + + if got[0].Severity != SeverityWarning { + t.Errorf("expected SeverityWarning, got %v", got[0].Severity) + } +} + +func TestServerURLReachableCheck_EmptyURLIsNoOp(t *testing.T) { + for _, url := range []string{"", " "} { + got := runServerURLReachableCheck(url) + if len(got) != 0 { + t.Errorf("expected no findings for empty URL %q, got %+v", url, got) + } + } +}