feat(service): pre-flight :443 reachability check with UI surfacing

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) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-15 11:03:12 +02:00
co-authored by Claude Opus 4.7
parent ef2b775ce0
commit 3727ae6f0f
9 changed files with 576 additions and 7 deletions
+41
View File
@@ -484,6 +484,8 @@ func main() {
} }
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL) startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
runHTTPSPreflight(config.httpsServerURL, config.serverURL, config.dnsEnabled, server.ResolveServerURLIPForPreflight)
}() }()
return http.ListenAndServe(config.addr, r) 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 // matchesDomain checks if a certificate domain (which may be a wildcard) matches a server name
func matchesDomain(certDomain, serverName string) bool { func matchesDomain(certDomain, serverName string) bool {
if certDomain == serverName { if certDomain == serverName {
+42 -3
View File
@@ -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. 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 ## How TLS works in AfterTouch
@@ -41,9 +49,40 @@ http://<server>:8000/setup/ca.crt
Speakers expect HTTPS on the default port 443. Since binding to port 443 requires elevated privileges, you have three options: 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. 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:
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). ```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 <IP> (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://<host>: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.
--- ---
+25
View File
@@ -105,6 +105,31 @@ iperf3 -c 192.168.1.1 # If iperf server available
## 🌐 **Connection Issues** ## 🌐 **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" ### ❌ "Connection refused"
**Symptoms:** **Symptoms:**
+14 -4
View File
@@ -180,6 +180,9 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
serverURLResolveError = err.Error() 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. // Mask secrets: return "***" if set so the UI can show "configured" without exposing the value.
if spotifyClientSecret != "" { if spotifyClientSecret != "" {
spotifyClientSecret = "***" spotifyClientSecret = "***"
@@ -190,10 +193,17 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
} }
if err := json.NewEncoder(w).Encode(map[string]interface{}{ if err := json.NewEncoder(w).Encode(map[string]interface{}{
"server_url": serverURL, "server_url": serverURL,
"server_url_resolved_ip": serverURLResolvedIP, "server_url_resolved_ip": serverURLResolvedIP,
"server_url_resolve_error": serverURLResolveError, "server_url_resolve_error": serverURLResolveError,
"https_server_url": httpsServerURL, "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_interval": discoveryInterval,
"discovery_enabled": discoveryEnabled, "discovery_enabled": discoveryEnabled,
"dns_enabled": dnsEnabled, "dns_enabled": dnsEnabled,
+176
View File
@@ -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 <binary> 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
}
+157
View File
@@ -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")
}
}
+7
View File
@@ -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 // 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 // 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 // hostname that cannot be resolved to an IP is reported as an error so callers
+1
View File
@@ -160,6 +160,7 @@
<input type="text" id="target-domain" placeholder="http://192.168.x.x:8000" style="width: 300px"/> <input type="text" id="target-domain" placeholder="http://192.168.x.x:8000" style="width: 300px"/>
<span style="font-size: 0.8em; color: #666">(Standard services URL)</span> <span style="font-size: 0.8em; color: #666">(Standard services URL)</span>
<div id="target-domain-resolved" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div> <div id="target-domain-resolved" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div>
<div id="https-443-status" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div>
</div> </div>
<div style="margin-bottom: 20px"> <div style="margin-bottom: 20px">
<strong>Device Discovery:</strong> <strong>Device Discovery:</strong>
+113
View File
@@ -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 <code>:443</code> on <code>" + lanHost + "</code>.";
} else if (outcome.elapsed >= FAST_ERROR_MS) {
color = "#2e7d32";
msg = "✅ Your browser reached <code>:" + lanHost + ":443</code> — the failure that follows is the expected " +
"untrusted-CA error, not a missing listener.";
} else {
color = "#c62828";
msg = "❌ Your browser sees no listener on <code>" + lanHost + ":443</code> " +
"(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 += " <em>(Server sees :443 but your browser doesn't — check intermediate firewalls / split-horizon DNS.)</em>";
color = "#c62828";
} else if (!serverLanOK && browserSees443) {
msg += " <em>(Your browser reaches :443 but the AfterTouch host can't — likely a host-firewall rule on the AfterTouch machine itself.)</em>";
color = "#c62828";
}
line.style.color = color;
line.innerHTML = msg;
}
async function fetchSpotifyStatus() { async function fetchSpotifyStatus() {
try { try {
const settingsResponse = await fetch("/setup/settings"); const settingsResponse = await fetch("/setup/settings");
@@ -139,6 +203,55 @@ async function fetchSettings() {
resolved.innerText = ""; 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 <code>:443</code> — 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 = "✅ <code>:443</code> reachable on <code>localhost</code> and <code>" +
(lanHost || "LAN address") + "</code> (forwarded to <code>:" + listenerPort + "</code>).";
} 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 <code>:443</code> but AfterTouch listens on <code>:" +
listenerPort + "</code>. " + details.join(" · ") +
". Set up iptables / setcap / reverse proxy — see " +
"<a href=\"https://github.com/gesellix/Bose-SoundTouch/blob/main/docs/guides/HTTPS-SETUP.md\" target=\"_blank\">HTTPS-SETUP.md</a>.";
}
// 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) { if (settings.discovery_interval) {
document.getElementById("discovery-interval").value = settings.discovery_interval; document.getElementById("discovery-interval").value = settings.discovery_interval;
} }