From 3727ae6f0f5109c16cb0de8055dfb98c268929fa Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Fri, 15 May 2026 09:57:20 +0200 Subject: [PATCH] feat(service): pre-flight :443 reachability check with UI surfacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Speakers connect to Bose hostnames over implicit HTTPS (:443) while AfterTouch's listener defaults to :8443. Without iptables / setcap / reverse-proxy in front, the speaker side sees Curl 7 / connection refused and AfterTouch's HTTP log stays silent — a recurring source of confusion (see #214, #269). Add a server-side probe (Check443Reachability) that dials both localhost:443 and the DNS-resolved LAN IP on :443. Run it once at service startup with a 2s timeout and emit a [WARN] log with the exact iptables/setcap commands keyed to the configured listener port. Expose the result via GET /setup/settings (with a shorter inline timeout) so the web UI renders a ✅/❌ line next to Target Domain and a complementary browser-side fetch probe — the browser sits on the LAN exactly where speakers do, and timing-to-error distinguishes TCP refused from TLS handshake started even with an untrusted CA. Both the startup WARN and the UI row are gated on dns_enabled, since :443 only matters for the DNS migration path; SDK-override migration uses the port from the configured URL. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/soundtouch-service/main.go | 41 ++++++ docs/guides/HTTPS-SETUP.md | 45 ++++++- docs/guides/TROUBLESHOOTING.md | 25 ++++ pkg/service/handlers/handlers_setup.go | 18 ++- pkg/service/handlers/preflight.go | 176 +++++++++++++++++++++++++ pkg/service/handlers/preflight_test.go | 157 ++++++++++++++++++++++ pkg/service/handlers/server.go | 7 + pkg/service/handlers/web/index.html | 1 + pkg/service/handlers/web/js/script.js | 113 ++++++++++++++++ 9 files changed, 576 insertions(+), 7 deletions(-) create mode 100644 pkg/service/handlers/preflight.go create mode 100644 pkg/service/handlers/preflight_test.go diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 9717f7e..231e4b5 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -484,6 +484,8 @@ func main() { } startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL) + + runHTTPSPreflight(config.httpsServerURL, config.serverURL, config.dnsEnabled, server.ResolveServerURLIPForPreflight) }() return http.ListenAndServe(config.addr, r) @@ -1184,6 +1186,45 @@ func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, h }() } +// runHTTPSPreflight checks whether speakers' implicit :443 target reaches +// AfterTouch. Runs after the HTTPS listener has had a moment to come up; if +// the listener is already on :443 the check is skipped. Emits a single WARN +// log line with actionable guidance when either probe fails. +// +// Only runs when dnsEnabled is true: the :443 reachability only matters when +// speakers are reaching AfterTouch via intercepted Bose hostnames (i.e. the +// DNS migration method). For direct SDK-override migration the speaker +// connects to the configured https-port directly, so :443 is irrelevant. +// Users with external DNS interception (Pi-hole, router rules) can still see +// the live result on /setup/settings even when this startup warn is silent. +func runHTTPSPreflight(httpsServerURL, serverURL string, dnsEnabled bool, resolver func(string) (string, error)) { + if !dnsEnabled { + return + } + + port := handlers.PortFromHTTPSServerURL(httpsServerURL) + if port == 0 { + // Can't determine the listener port — be silent rather than misleading. + return + } + + // Give the listener a head start so a successful bind beats the probe. + time.Sleep(2 * time.Second) + + res := handlers.Check443Reachability(port, serverURL, resolver, handlers.ProbeDialTimeoutStartup) + + guidance := handlers.FormatPreflightGuidance(port, res) + if guidance == "" { + if !res.Skipped { + log.Printf("HTTPS pre-flight: :443 reachable at localhost and %s ✓", res.LANHost) + } + + return + } + + log.Print(guidance) +} + // matchesDomain checks if a certificate domain (which may be a wildcard) matches a server name func matchesDomain(certDomain, serverName string) bool { if certDomain == serverName { diff --git a/docs/guides/HTTPS-SETUP.md b/docs/guides/HTTPS-SETUP.md index b67c6d5..0d161d2 100644 --- a/docs/guides/HTTPS-SETUP.md +++ b/docs/guides/HTTPS-SETUP.md @@ -2,6 +2,14 @@ SoundTouch speakers communicate with cloud services over HTTPS. For the local service to work over HTTPS, speakers must trust the AfterTouch Root CA. The service manages this automatically — it generates a CA on first start and the web UI guides you through installing it on each speaker as part of the migration flow. +> ### ⚠️ Speakers connect to `:443`, AfterTouch defaults to `:8443` +> +> Speakers build their target URLs from Bose hostnames *without* an explicit port, so they connect on the default HTTPS port **443**. AfterTouch's built-in HTTPS listener defaults to **8443** because port 443 is privileged on most Unix systems. +> +> **If you do nothing, speakers will fail with `Curl 7` / connection refused and nothing will appear in the AfterTouch HTTP log.** +> +> Pick one of the three options under [Binding to port 443](#binding-to-port-443) below. The settings page in the web UI shows a ✅ / ❌ indicator for `:443` reachability so you can confirm the routing is in place. + --- ## How TLS works in AfterTouch @@ -41,9 +49,40 @@ http://:8000/setup/ca.crt Speakers expect HTTPS on the default port 443. Since binding to port 443 requires elevated privileges, you have three options: -1. **Port forwarding (recommended)**: Run the service on port 8443 and forward port 443 to it using `iptables` or your firewall/router. -2. **Capabilities**: Grant the binary permission to bind low ports: `sudo setcap 'cap_net_bind_service=+ep' ./soundtouch-service` -3. **Reverse proxy**: Use Nginx or Caddy in front of the service (see below). +1. **Port forwarding (recommended)**: Run the service on port 8443 and forward port 443 to it using `iptables` or your firewall/router. Inside an LXC/Docker container or on the host: + + ```bash + iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 8443 + iptables -t nat -A OUTPUT -p tcp --dport 443 -j REDIRECT --to-port 8443 + ``` + + The first rule covers traffic arriving from speakers; the second covers loopback connections from the host itself (useful for the in-built pre-flight probe). + +2. **Capabilities**: Grant the binary permission to bind low ports and start the listener directly on `:443`: + + ```bash + sudo setcap 'cap_net_bind_service=+ep' ./soundtouch-service + ./soundtouch-service --https-port=443 + ``` + +3. **Reverse proxy**: Use Nginx or Caddy on `:443` in front of the service (see below). + +### Confirming `:443` is reachable + +After applying any of the options above, open the AfterTouch web UI → **Settings**. The Target Domain row will show a second line: + +* ✅ `:443 reachable on localhost and (forwarded to :8443)` — you're good. +* ❌ `Speakers connect to :443 but AfterTouch listens on :8443.` — the routing is missing or not yet active. + +A third line follows from the browser itself, which sits on the LAN exactly where the speakers do. The browser can't distinguish an untrusted-CA TLS error from a connection refusal, so it uses timing as a heuristic: a fast error means "no listener / firewall reset", a slower one means "something answered TCP". When the server-side and browser-side checks disagree, the UI flags it — that almost always means NAT, split-horizon DNS, or a host firewall sitting between AfterTouch and the LAN. + +The same check runs once at service startup and prints a `[WARN]` log line if `:443` is unreachable, with the exact iptables/setcap commands for your current listener port. + +#### When this check is shown + +The `:443` indicator is only displayed when **AfterTouch's DNS interception is enabled** (Settings → "Enable DNS Discovery Server"). The check is only meaningful for the **DNS migration method**, where speakers reach AfterTouch via intercepted Bose hostnames and therefore on the implicit `:443`. The other migration method — writing direct `https://:8443/...` URLs into the speaker's private config via SSH — uses the port that's literally in the URL, so `:443` is irrelevant and the check would only add noise. + +If you intercept Bose hostnames **outside** AfterTouch (Pi-hole, router DNS rule, `/etc/hosts` on a gateway), the UI gate above will hide the indicator. The data is still in the `GET /setup/settings` JSON response (`https_443_localhost_reachable`, `https_443_lan_reachable`, `https_443_lan_host`) if you want to inspect it directly, or you can briefly enable AfterTouch's DNS server to see the indicator render. --- diff --git a/docs/guides/TROUBLESHOOTING.md b/docs/guides/TROUBLESHOOTING.md index 088bc1d..a170ecc 100644 --- a/docs/guides/TROUBLESHOOTING.md +++ b/docs/guides/TROUBLESHOOTING.md @@ -105,6 +105,31 @@ iperf3 -c 192.168.1.1 # If iperf server available ## 🌐 **Connection Issues** +### ❌ Speaker logs `Curl 7, http 0` and AfterTouch sees no HTTP requests + +**Symptoms:** + +In the speaker's log (`logread -f` over SSH): + +``` +SimpleURLFetcher: retry needed, Curl 7, http 0 +``` + +In the AfterTouch service log: plenty of `[DNS] Intercepted query …` lines but **zero** HTTP requests after each DNS lookup. + +**Cause:** speakers connect to Bose hostnames over implicit HTTPS, i.e. port **443**. AfterTouch's built-in HTTPS listener defaults to **8443** because 443 is privileged. The speaker resolves the right IP, dials `:443`, and gets connection refused — which is what `Curl 7` reports. + +**Verify:** + +```bash +curl -ksS -o /dev/null -w "443=%{http_code}\n" https://localhost:443/ +curl -ksS -o /dev/null -w "8443=%{http_code}\n" https://localhost:8443/ +``` + +Expected when the misconfiguration is present: `443=000` plus a `curl: (7) Failed to connect …` line, `8443=200` (or any 3-digit code). + +**Fix:** route `:443` to AfterTouch's HTTPS listener — see [HTTPS-SETUP.md → Binding to port 443](HTTPS-SETUP.md#binding-to-port-443). The AfterTouch settings page shows a ✅ / ❌ indicator for `:443` reachability once the routing is in place. + ### ❌ "Connection refused" **Symptoms:** diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index 8df6797..e946627 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -180,6 +180,9 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { serverURLResolveError = err.Error() } + httpsListenerPort := PortFromHTTPSServerURL(httpsServerURL) + probe443 := Check443Reachability(httpsListenerPort, serverURL, s.resolveServerURLIP, ProbeDialTimeoutInline) + // Mask secrets: return "***" if set so the UI can show "configured" without exposing the value. if spotifyClientSecret != "" { spotifyClientSecret = "***" @@ -190,10 +193,17 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { } if err := json.NewEncoder(w).Encode(map[string]interface{}{ - "server_url": serverURL, - "server_url_resolved_ip": serverURLResolvedIP, - "server_url_resolve_error": serverURLResolveError, - "https_server_url": httpsServerURL, + "server_url": serverURL, + "server_url_resolved_ip": serverURLResolvedIP, + "server_url_resolve_error": serverURLResolveError, + "https_server_url": httpsServerURL, + "https_listener_port": httpsListenerPort, + "https_443_check_skipped": probe443.Skipped, + "https_443_localhost_reachable": probe443.Localhost.Reachable, + "https_443_localhost_error": probe443.Localhost.Error, + "https_443_lan_reachable": probe443.LAN.Reachable, + "https_443_lan_error": probe443.LAN.Error, + "https_443_lan_host": probe443.LANHost, "discovery_interval": discoveryInterval, "discovery_enabled": discoveryEnabled, "dns_enabled": dnsEnabled, diff --git a/pkg/service/handlers/preflight.go b/pkg/service/handlers/preflight.go new file mode 100644 index 0000000..454428c --- /dev/null +++ b/pkg/service/handlers/preflight.go @@ -0,0 +1,176 @@ +package handlers + +import ( + "fmt" + "net" + "net/url" + "strconv" + "time" +) + +// Probe443Result captures the outcome of probing a host on :443. +// Skipped is true when the running HTTPS listener is already on :443 +// (in which case the listener itself is the proof of reachability). +type Probe443Result struct { + Skipped bool + Localhost ProbeOutcome + LAN ProbeOutcome + LANHost string +} + +// ProbeOutcome describes a single TCP-connect probe. Exactly one of +// Reachable/Error is meaningful: Reachable=true means the dial succeeded, +// otherwise Error holds the dial error string. +type ProbeOutcome struct { + Reachable bool + Error string +} + +// ProbeDialTimeoutStartup is the per-attempt TCP dial timeout used by the +// startup preflight, where we can afford to wait a beat for a slow LAN. +const ProbeDialTimeoutStartup = 2 * time.Second + +// ProbeDialTimeoutInline is the per-attempt TCP dial timeout used by the +// settings HTTP handler, where a user is blocking on the response. +const ProbeDialTimeoutInline = 500 * time.Millisecond + +// ProbeTCP attempts a TCP connection to host:port within timeout. It returns +// nil on success; an error otherwise. The connection is closed immediately — +// we only care whether *something* would answer where a speaker knocks. +func ProbeTCP(host string, port int, timeout time.Duration) error { + addr := net.JoinHostPort(host, strconv.Itoa(port)) + + conn, err := net.DialTimeout("tcp", addr, timeout) + if err != nil { + return err + } + + _ = conn.Close() + + return nil +} + +// Check443Reachability probes both localhost:443 and the LAN-facing IP that +// DNS would hand out for serverURL on :443. It is intended to surface the +// most common AfterTouch misconfiguration: HTTPS listener on :8443 with no +// routing in place from :443 (speakers connect to implicit :443 and see +// Curl 7 / connection refused with nothing reaching AfterTouch). +// +// If httpsListenerPort is already 443, both probes are skipped — the running +// listener proves :443 is reachable. +// +// lanResolver is the function used to translate serverURL into a LAN IP; in +// production this is Server.resolveServerURLIP. It is injected so this can +// be tested without a full Server. +func Check443Reachability( + httpsListenerPort int, + serverURL string, + lanResolver func(string) (string, error), + timeout time.Duration, +) Probe443Result { + if httpsListenerPort == 443 { + return Probe443Result{Skipped: true} + } + + res := Probe443Result{} + + if err := ProbeTCP("127.0.0.1", 443, timeout); err != nil { + res.Localhost.Error = err.Error() + } else { + res.Localhost.Reachable = true + } + + lanIP, resolveErr := lanResolver(serverURL) + if resolveErr != nil { + res.LAN.Error = "cannot resolve LAN target: " + resolveErr.Error() + return res + } + + res.LANHost = lanIP + + if err := ProbeTCP(lanIP, 443, timeout); err != nil { + res.LAN.Error = err.Error() + } else { + res.LAN.Reachable = true + } + + return res +} + +// PortFromHTTPSServerURL extracts the numeric port from httpsServerURL. It +// returns 0 if the URL is empty, malformed, or has no explicit port — in +// that case the caller cannot make a determination about :443 and should +// treat the result as "unknown" rather than "definitely not 443". +func PortFromHTTPSServerURL(httpsServerURL string) int { + if httpsServerURL == "" { + return 0 + } + + u, err := url.Parse(httpsServerURL) + if err != nil { + return 0 + } + + portStr := u.Port() + if portStr == "" { + return 0 + } + + port, err := strconv.Atoi(portStr) + if err != nil { + return 0 + } + + return port +} + +// FormatPreflightGuidance returns a multi-line, human-readable warning +// summarising a failing Probe443Result, with actionable next steps. The +// returned string ends without a trailing newline so callers may use it +// with log.Print or log.Printf as they prefer. +func FormatPreflightGuidance(httpsListenerPort int, res Probe443Result) string { + if res.Skipped { + return "" + } + + if res.Localhost.Reachable && res.LAN.Reachable { + return "" + } + + lines := []string{ + fmt.Sprintf("[WARN] HTTPS pre-flight: speakers connect to :443 but AfterTouch listens on :%d.", httpsListenerPort), + } + + if res.Localhost.Reachable { + lines = append(lines, " - localhost:443: reachable ✓") + } else { + lines = append(lines, " - localhost:443: "+res.Localhost.Error) + } + + if res.LAN.Reachable { + lines = append(lines, fmt.Sprintf(" - %s:443 (LAN): reachable ✓", res.LANHost)) + } else if res.LANHost != "" { + lines = append(lines, fmt.Sprintf(" - %s:443 (LAN): %s", res.LANHost, res.LAN.Error)) + } else { + lines = append(lines, " - LAN: "+res.LAN.Error) + } + + lines = append(lines, + " Speakers will fail with Curl 7 / connection refused until :443 is routed to AfterTouch. Options:", + " 1. iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port "+strconv.Itoa(httpsListenerPort), + " 2. setcap cap_net_bind_service=+ep and pass --https-port=443", + " 3. reverse proxy (nginx/caddy) terminating TLS on :443", + " See docs/guides/HTTPS-SETUP.md for details.", + ) + + out := "" + for i, l := range lines { + if i > 0 { + out += "\n" + } + + out += l + } + + return out +} diff --git a/pkg/service/handlers/preflight_test.go b/pkg/service/handlers/preflight_test.go new file mode 100644 index 0000000..eeed379 --- /dev/null +++ b/pkg/service/handlers/preflight_test.go @@ -0,0 +1,157 @@ +package handlers + +import ( + "net" + "strings" + "testing" + "time" +) + +func TestProbeTCP_OpenPortSucceeds(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to start listener: %v", err) + } + defer ln.Close() + + port := ln.Addr().(*net.TCPAddr).Port + + if err := ProbeTCP("127.0.0.1", port, 500*time.Millisecond); err != nil { + t.Errorf("expected probe of open port to succeed, got: %v", err) + } +} + +func TestProbeTCP_ClosedPortFails(t *testing.T) { + // Bind, capture port, close — leaves the port verifiably unbound. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to start listener: %v", err) + } + + port := ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + + if err := ProbeTCP("127.0.0.1", port, 500*time.Millisecond); err == nil { + t.Errorf("expected probe of closed port to fail, got nil") + } +} + +func TestCheck443Reachability_SkipsWhenListenerOn443(t *testing.T) { + res := Check443Reachability(443, "http://example.test:8000", func(string) (string, error) { + t.Errorf("resolver should not be called when listener is on :443") + return "", nil + }, 100*time.Millisecond) + + if !res.Skipped { + t.Errorf("expected Skipped=true when httpsListenerPort=443, got %+v", res) + } +} + +func TestCheck443Reachability_ReportsResolverError(t *testing.T) { + res := Check443Reachability(8443, "http://broken", func(string) (string, error) { + return "", errResolve("no DNS") + }, 100*time.Millisecond) + + if res.Skipped { + t.Errorf("expected Skipped=false, got true") + } + + if res.LAN.Reachable { + t.Errorf("expected LAN.Reachable=false, got true") + } + + if !strings.Contains(res.LAN.Error, "cannot resolve LAN target") { + t.Errorf("expected LAN.Error to wrap resolver failure, got %q", res.LAN.Error) + } +} + +func TestPortFromHTTPSServerURL(t *testing.T) { + cases := []struct { + in string + want int + }{ + {"", 0}, + {"https://example.test:8443", 8443}, + {"https://example.test:443", 443}, + {"https://example.test", 0}, + {":::not a url", 0}, + } + + for _, tc := range cases { + got := PortFromHTTPSServerURL(tc.in) + if got != tc.want { + t.Errorf("PortFromHTTPSServerURL(%q) = %d, want %d", tc.in, got, tc.want) + } + } +} + +func TestFormatPreflightGuidance_SkippedAndAllOK(t *testing.T) { + if FormatPreflightGuidance(443, Probe443Result{Skipped: true}) != "" { + t.Errorf("expected empty guidance when skipped") + } + + bothOK := Probe443Result{ + Localhost: ProbeOutcome{Reachable: true}, + LAN: ProbeOutcome{Reachable: true}, + LANHost: "10.0.0.1", + } + if FormatPreflightGuidance(8443, bothOK) != "" { + t.Errorf("expected empty guidance when both probes succeed") + } +} + +func TestFormatPreflightGuidance_BothFailMentionsRedirectPort(t *testing.T) { + res := Probe443Result{ + Localhost: ProbeOutcome{Error: "connection refused"}, + LAN: ProbeOutcome{Error: "connection refused"}, + LANHost: "192.168.1.151", + } + + out := FormatPreflightGuidance(8443, res) + if !strings.Contains(out, "--to-port 8443") { + t.Errorf("guidance must reference configured listener port for iptables, got: %s", out) + } + + if !strings.Contains(out, "192.168.1.151:443") { + t.Errorf("guidance must mention probed LAN host, got: %s", out) + } + + if !strings.Contains(out, "[WARN]") { + t.Errorf("guidance must be marked as a warning, got: %s", out) + } +} + +type errResolve string + +func (e errResolve) Error() string { return string(e) } + +func TestCheck443Reachability_LANProbeMatchesListenerOutcome(t *testing.T) { + // Spin up a listener on a random port and use that port via resolver + // trickery: we point the LAN host at 127.0.0.1 and rely on the fact that + // nothing answers on :443 in test environments. The point of this test + // is to lock in the result-shape: when localhost:443 is closed (the + // default in CI), the function still returns a well-formed result and + // reports the resolved LAN host. + res := Check443Reachability(8443, "http://1.2.3.4:8000", func(string) (string, error) { + return "1.2.3.4", nil + }, 200*time.Millisecond) + + if res.Skipped { + t.Fatalf("expected Skipped=false, got true") + } + + if res.LANHost != "1.2.3.4" { + t.Errorf("expected LANHost=1.2.3.4, got %q", res.LANHost) + } + + // In any sane CI environment nothing is listening on :443, so both + // probes should report errors. We don't assert the exact error string + // (varies by OS) but we do assert it's populated. + if res.LAN.Reachable { + t.Errorf("did not expect LAN:443 to be reachable in test env") + } + + if res.LAN.Error == "" { + t.Errorf("expected LAN.Error to be populated when unreachable") + } +} diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 0168803..39e7f87 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -238,6 +238,13 @@ func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) { } } +// ResolveServerURLIPForPreflight is an exported wrapper around resolveServerURLIP +// so callers outside the package (e.g. the service startup pre-flight) can +// reuse the same resolution path the DNS server uses. +func (s *Server) ResolveServerURLIPForPreflight(serverURL string) (string, error) { + return s.resolveServerURLIP(serverURL) +} + // resolveServerURLIP returns the IP that the DNS server would hand out as the // intercept answer for the given server URL. An empty URL, empty hostname, or a // hostname that cannot be resolved to an IP is reported as an error so callers diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index c814d88..4f92e9e 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -160,6 +160,7 @@ (Standard services URL)
+
Device Discovery: diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js index cfa8ad2..fe403d5 100644 --- a/pkg/service/handlers/web/js/script.js +++ b/pkg/service/handlers/web/js/script.js @@ -1,3 +1,67 @@ +// FAST_ERROR_MS is the timing threshold used to distinguish "no listener +// on :443" (very fast browser error, usually TCP RST) from "something +// answered TCP, TLS handshake failed because of untrusted cert" (slower +// error). The exact cutoff is fuzzy and varies by browser/network, but +// the gap between the two cases is large enough (single-digit ms vs. +// 100+ ms) that this works as a heuristic. We don't expose milliseconds +// to the user — they'd be misleading without context. +const FAST_ERROR_MS = 150; + +async function probeBrowser443(lanHost, listenerPort, statusEl, serverLocalhostOK, serverLanOK) { + const line = document.createElement("div"); + line.style.fontSize = "0.85em"; + line.style.marginTop = "2px"; + line.style.color = "#666"; + line.innerText = "⏱ Checking from your browser too…"; + statusEl.appendChild(line); + + const start = performance.now(); + let outcome; + try { + // mode:"no-cors" lets the request go on the wire even though the response + // would be opaque. We only care about success-or-fail and timing — not + // the response body, which we can't read anyway with an untrusted cert. + await fetch("https://" + lanHost + ":443/", { + mode: "no-cors", + cache: "no-store", + signal: AbortSignal.timeout(2000), + }); + outcome = { reached: true, elapsed: performance.now() - start }; + } catch (e) { + outcome = { reached: false, elapsed: performance.now() - start, err: e }; + } + + let msg; + let color; + if (outcome.reached) { + color = "#2e7d32"; + msg = "✅ Your browser also reaches :443 on " + lanHost + "."; + } else if (outcome.elapsed >= FAST_ERROR_MS) { + color = "#2e7d32"; + msg = "✅ Your browser reached :" + lanHost + ":443 — the failure that follows is the expected " + + "untrusted-CA error, not a missing listener."; + } else { + color = "#c62828"; + msg = "❌ Your browser sees no listener on " + lanHost + ":443 " + + "(fast error, likely connection refused)."; + } + + // Hint when server and browser disagree — that almost always means NAT, + // split-horizon DNS, or a host firewall sitting between AfterTouch and + // the speaker. Worth pointing out because it's invisible to the server. + const browserSees443 = outcome.reached || outcome.elapsed >= FAST_ERROR_MS; + if (serverLanOK && !browserSees443) { + msg += " (Server sees :443 but your browser doesn't — check intermediate firewalls / split-horizon DNS.)"; + color = "#c62828"; + } else if (!serverLanOK && browserSees443) { + msg += " (Your browser reaches :443 but the AfterTouch host can't — likely a host-firewall rule on the AfterTouch machine itself.)"; + color = "#c62828"; + } + + line.style.color = color; + line.innerHTML = msg; +} + async function fetchSpotifyStatus() { try { const settingsResponse = await fetch("/setup/settings"); @@ -139,6 +203,55 @@ async function fetchSettings() { resolved.innerText = ""; } } + + const port443 = document.getElementById("https-443-status"); + if (port443) { + // The :443 check only applies to the DNS-migration path. Hide the row + // entirely when AfterTouch's DNS interception is off — those users are + // either using SDK overrides (port-explicit URLs) or external DNS + // interception (in which case they can read /setup/settings JSON + // directly if they want the result). + if (!settings.dns_enabled) { + port443.innerHTML = ""; + } else if (settings.https_443_check_skipped) { + port443.style.color = "#2e7d32"; + port443.innerHTML = "✅ HTTPS listener bound directly to :443 — speakers can connect."; + } else { + const localhostOK = settings.https_443_localhost_reachable; + const lanOK = settings.https_443_lan_reachable; + const lanHost = settings.https_443_lan_host || ""; + const listenerPort = settings.https_listener_port || "8443"; + if (localhostOK && lanOK) { + port443.style.color = "#2e7d32"; + port443.innerHTML = "✅ :443 reachable on localhost and " + + (lanHost || "LAN address") + " (forwarded to :" + listenerPort + ")."; + } else { + port443.style.color = "#c62828"; + const details = []; + details.push("localhost:443 " + + (localhostOK ? "✓" : "❌ " + (settings.https_443_localhost_error || "unreachable"))); + details.push((lanHost || "LAN") + ":443 " + + (lanOK ? "✓" : "❌ " + (settings.https_443_lan_error || "unreachable"))); + port443.innerHTML = "❌ Speakers connect to :443 but AfterTouch listens on :" + + listenerPort + ". " + details.join(" · ") + + ". Set up iptables / setcap / reverse proxy — see " + + "HTTPS-SETUP.md."; + } + + // Browser-side probe runs in parallel. Mirrors what speakers see from + // the LAN; the server-side probe runs from inside AfterTouch's host + // and can disagree when there is NAT / split-horizon / a firewall in + // between. We can't see TLS-cert vs. TCP-RST from JS, so we fall back + // to timing: a fast error suggests no listener; a slower error + // suggests the connection got far enough to start TLS, which proves + // something is answering. The CA cert is not trusted by the browser + // by default, so a clean ✅ resolution is rare — that's fine, the + // timing alone is the diagnostic signal. + if (lanHost) { + probeBrowser443(lanHost, listenerPort, port443, localhostOK, lanOK); + } + } + } if (settings.discovery_interval) { document.getElementById("discovery-interval").value = settings.discovery_interval; }