diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 35eeaeb..4744f68 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -1304,7 +1304,12 @@ func runHTTPSPreflight(httpsServerURL, serverURL string, dnsEnabled bool, resolv guidance := handlers.FormatPreflightGuidance(port, res) if guidance == "" { - if !res.Skipped { + switch { + case res.Skipped: + // Listener already on :443 — nothing to say. + case res.NotApplicable: + log.Printf("HTTPS pre-flight: :443 check skipped — %s", res.Reason) + default: log.Printf("HTTPS pre-flight: :443 reachable at localhost and %s ✓", res.LANHost) } diff --git a/docs/guides/HTTPS-SETUP.md b/docs/guides/HTTPS-SETUP.md index 0d161d2..ef3595c 100644 --- a/docs/guides/HTTPS-SETUP.md +++ b/docs/guides/HTTPS-SETUP.md @@ -58,6 +58,8 @@ Speakers expect HTTPS on the default port 443. Since binding to port 443 require 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). + > **Caveat — OUTPUT chain.** The second rule catches **all** outbound `:443` traffic from this host, including the AfterTouch host's own connections to the wider internet (browsers, `go install` against `proxy.golang.org`, `apt-get`, `git clone https://...`, etc.). Speakers reaching AfterTouch from the LAN only ever pass through `PREROUTING`. If you don't run the in-built pre-flight probe from this host, or if you've seen other software break with TLS errors after adding both rules, add only the `PREROUTING` rule and skip `OUTPUT`. The pre-flight's "localhost:443" probe will then report unreachable — that's expected and harmless. + 2. **Capabilities**: Grant the binary permission to bind low ports and start the listener directly on `:443`: ```bash @@ -82,7 +84,11 @@ The same check runs once at service startup and prints a `[WARN]` log line if `: 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. +#### Not applicable in HTTP-only deployments + +When AfterTouch's configured `--server-url` is `http://…`, the pre-flight short-circuits to an `ℹ️ :443 reachability check not applicable` info line. Speakers that were migrated to that HTTP URL never connect to `:443`, so the iptables / setcap / reverse-proxy work is only needed if you also expect unmigrated speakers to fall back to `streaming.bose.com:443` via DNS hijack. If that's not your situation, the iptables rules above are optional. + +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`, `https_443_not_applicable`, `https_443_reason`) if you want to inspect it directly, or you can briefly enable AfterTouch's DNS server to see the indicator render. --- diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index 9a06a28..b7bc9b6 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -195,6 +195,8 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { "https_server_url": httpsServerURL, "https_listener_port": httpsListenerPort, "https_443_check_skipped": probe443.Skipped, + "https_443_not_applicable": probe443.NotApplicable, + "https_443_reason": probe443.Reason, "https_443_localhost_reachable": probe443.Localhost.Reachable, "https_443_localhost_error": probe443.Localhost.Error, "https_443_lan_reachable": probe443.LAN.Reachable, diff --git a/pkg/service/handlers/preflight.go b/pkg/service/handlers/preflight.go index deef8a8..ac50e9b 100644 --- a/pkg/service/handlers/preflight.go +++ b/pkg/service/handlers/preflight.go @@ -5,17 +5,26 @@ import ( "net" "net/url" "strconv" + "strings" "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). +// NotApplicable is true when the operator has chosen an HTTP-only +// deployment (configured serverURL is http://...) — speakers migrated +// to that URL never connect to :443, so the iptables/setcap dance +// would only matter for unmigrated speakers falling back to +// streaming.bose.com via DNS hijack. Reason carries a short +// human-readable explanation rendered in the UI. type Probe443Result struct { - Skipped bool - Localhost ProbeOutcome - LAN ProbeOutcome - LANHost string + Skipped bool + NotApplicable bool + Reason string + Localhost ProbeOutcome + LAN ProbeOutcome + LANHost string } // ProbeOutcome describes a single TCP-connect probe. Exactly one of @@ -72,6 +81,14 @@ func Check443Reachability( return Probe443Result{Skipped: true} } + if scheme := schemeOf(serverURL); scheme == "http" { + return Probe443Result{ + NotApplicable: true, + Reason: "AfterTouch's configured serverURL is HTTP, so migrated speakers connect over plain HTTP and never use :443. " + + "The iptables / setcap / reverse-proxy dance is only needed if you also expect unmigrated speakers to fall back to streaming.bose.com via DNS hijack.", + } + } + res := Probe443Result{} if err := ProbeTCP("127.0.0.1", 443, timeout); err != nil { @@ -97,6 +114,22 @@ func Check443Reachability( return res } +// schemeOf returns the lowercased URL scheme of s, or "" if s is empty or +// unparseable. Used to decide whether the :443 reachability check is even +// applicable to the deployment. +func schemeOf(s string) string { + if s == "" { + return "" + } + + u, err := url.Parse(s) + if err != nil { + return "" + } + + return strings.ToLower(u.Scheme) +} + // 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 @@ -129,7 +162,7 @@ func PortFromHTTPSServerURL(httpsServerURL string) int { // 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 { + if res.Skipped || res.NotApplicable { return "" } @@ -161,6 +194,7 @@ func FormatPreflightGuidance(httpsListenerPort int, res Probe443Result) string { " 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", + " Caveat: do NOT add the same REDIRECT rule on the OUTPUT chain. That would catch this host's own outbound :443 traffic (browsers, `go install`, `apt-get`) and route it to AfterTouch.", " See docs/guides/HTTPS-SETUP.md for details.", ) diff --git a/pkg/service/handlers/preflight_test.go b/pkg/service/handlers/preflight_test.go index aedc9a3..77b102e 100644 --- a/pkg/service/handlers/preflight_test.go +++ b/pkg/service/handlers/preflight_test.go @@ -48,7 +48,8 @@ func TestCheck443Reachability_SkipsWhenListenerOn443(t *testing.T) { } func TestCheck443Reachability_ReportsResolverError(t *testing.T) { - res := Check443Reachability(8443, "http://broken", func(string) (string, error) { + // Use an HTTPS server URL so the NotApplicable short-circuit doesn't fire. + res := Check443Reachability(8443, "https://broken", func(string) (string, error) { return "", errResolve("no DNS") }, 100*time.Millisecond) @@ -65,6 +66,57 @@ func TestCheck443Reachability_ReportsResolverError(t *testing.T) { } } +func TestCheck443Reachability_NotApplicableWhenServerURLIsHTTP(t *testing.T) { + res := Check443Reachability(8443, "http://aftertouch.local:8000", func(string) (string, error) { + t.Errorf("resolver should not be called when serverURL scheme is HTTP") + return "", nil + }, 100*time.Millisecond) + + if !res.NotApplicable { + t.Errorf("expected NotApplicable=true when serverURL is HTTP, got %+v", res) + } + + if res.Reason == "" { + t.Errorf("expected NotApplicable verdict to carry a human-readable Reason, got empty") + } + + if res.Skipped { + t.Errorf("Skipped should only be set when the listener is already on :443; got Skipped=true for HTTP serverURL") + } +} + +func TestCheck443Reachability_NotApplicableTakesPrecedenceOverProbe(t *testing.T) { + // Even if the listener isn't on :443 and probes would fail, an HTTP + // serverURL should short-circuit to NotApplicable. + res := Check443Reachability(8443, "http://1.2.3.4:8000", func(string) (string, error) { + return "1.2.3.4", nil + }, 100*time.Millisecond) + + if !res.NotApplicable { + t.Errorf("expected NotApplicable=true, got %+v", res) + } + + if res.LAN.Error != "" || res.Localhost.Error != "" { + t.Errorf("expected no probe errors when NotApplicable short-circuits, got %+v", res) + } +} + +func TestCheck443Reachability_HTTPSServerURLStillProbes(t *testing.T) { + resolverCalled := false + res := Check443Reachability(8443, "https://aftertouch.local:8443", func(string) (string, error) { + resolverCalled = true + return "127.0.0.1", nil + }, 100*time.Millisecond) + + if !resolverCalled { + t.Errorf("resolver should be called for HTTPS serverURL") + } + + if res.NotApplicable { + t.Errorf("HTTPS serverURL should not produce NotApplicable, got %+v", res) + } +} + func TestPortFromHTTPSServerURL(t *testing.T) { cases := []struct { in string @@ -98,6 +150,10 @@ func TestFormatPreflightGuidance_SkippedAndAllOK(t *testing.T) { if FormatPreflightGuidance(8443, bothOK) != "" { t.Errorf("expected empty guidance when both probes succeed") } + + if FormatPreflightGuidance(8443, Probe443Result{NotApplicable: true, Reason: "HTTP only"}) != "" { + t.Errorf("expected empty guidance when NotApplicable (UI renders the reason separately)") + } } func TestFormatPreflightGuidance_BothFailMentionsRedirectPort(t *testing.T) { @@ -121,6 +177,19 @@ func TestFormatPreflightGuidance_BothFailMentionsRedirectPort(t *testing.T) { } } +func TestFormatPreflightGuidance_IncludesOutputChainCaveat(t *testing.T) { + res := Probe443Result{ + Localhost: ProbeOutcome{Error: "connection refused"}, + LAN: ProbeOutcome{Error: "connection refused"}, + LANHost: "192.0.2.151", + } + + out := FormatPreflightGuidance(8443, res) + if !strings.Contains(out, "OUTPUT") { + t.Errorf("guidance must warn about the iptables OUTPUT chain side-effect, got: %s", out) + } +} + type errResolve string func (e errResolve) Error() string { return string(e) } @@ -131,8 +200,9 @@ func TestCheck443Reachability_LANProbeMatchesListenerOutcome(t *testing.T) { // 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) { + // reports the resolved LAN host. Uses HTTPS so the NotApplicable + // short-circuit doesn't fire. + res := Check443Reachability(8443, "https://1.2.3.4:8443", func(string) (string, error) { return "1.2.3.4", nil }, 200*time.Millisecond) diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js index bd5852b..792e81b 100644 --- a/pkg/service/handlers/web/js/script.js +++ b/pkg/service/handlers/web/js/script.js @@ -216,6 +216,10 @@ async function fetchSettings() { } else if (settings.https_443_check_skipped) { port443.style.color = "#2e7d32"; port443.innerHTML = "✅ HTTPS listener bound directly to :443 — speakers can connect."; + } else if (settings.https_443_not_applicable) { + port443.style.color = "#1565c0"; + port443.innerHTML = "ℹ️ :443 reachability check not applicable. " + + (settings.https_443_reason || ""); } else { const localhostOK = settings.https_443_localhost_reachable; const lanOK = settings.https_443_lan_reachable;