feat(preflight): skip :443 check in HTTP-only deployments + OUTPUT-chain caveat

The :443 reachability preflight was emitting a WARN on every deployment
where AfterTouch's configured --server-url is HTTP (not HTTPS), even
when speakers were migrated to that HTTP URL and never connect to :443.
Operators reproduced this on #218 (CTonyPeterson) and #344
(california444) — both saw the warning even though their setups had no
need for iptables port forwarding, and CTonyPeterson followed the
recommended iptables OUTPUT rule which then caught his host's own
outbound HTTPS traffic and broke `go install` and his browser.

Two changes:

- Probe443Result gains NotApplicable + Reason. Check443Reachability
  returns the NotApplicable verdict when the parsed serverURL scheme is
  http. The settings UI renders an ℹ️ info badge with the reason instead
  of a red ✗.
- FormatPreflightGuidance grows a one-line caveat about the iptables
  OUTPUT chain: it catches all outbound :443 on the host, including
  browsers / go install / apt-get, which is rarely what the operator
  wants.

HTTPS-SETUP.md gains the same caveat plus a section documenting the
new not-applicable verdict.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-22 20:32:44 +02:00
co-authored by Claude Sonnet 4.6
parent a1754a4500
commit 377fa9ceda
6 changed files with 131 additions and 10 deletions
+6 -1
View File
@@ -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)
}
+7 -1
View File
@@ -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://<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.
#### 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.
---
+2
View File
@@ -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,
+39 -5
View File
@@ -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 <binary> 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.",
)
+73 -3
View File
@@ -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)
+4
View File
@@ -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 <code>:443</code> — speakers can connect.";
} else if (settings.https_443_not_applicable) {
port443.style.color = "#1565c0";
port443.innerHTML = "️ <code>:443</code> reachability check not applicable. " +
(settings.https_443_reason || "");
} else {
const localhostOK = settings.https_443_localhost_reachable;
const lanOK = settings.https_443_lan_reachable;