mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
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:
co-authored by
Claude Opus 4.7
parent
ef2b775ce0
commit
3727ae6f0f
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user