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) + } + } +}