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)
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 {
+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.
> ### ⚠️ 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://<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:
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 <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**
### ❌ 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:**
+14 -4
View File
@@ -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,
+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
// 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
+1
View File
@@ -160,6 +160,7 @@
<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>
<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 style="margin-bottom: 20px">
<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() {
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 <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) {
document.getElementById("discovery-interval").value = settings.discovery_interval;
}