From 93248659a5b46b60cd6a9536e988c5fd8ad84397 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 23 May 2026 00:28:30 +0200 Subject: [PATCH] fix(tls): also cover derived OAuth subdomain in served cert SAN list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #337's first commit added the OAuth-derivation to the DNS interceptor but missed the served TLS certificate. With a serverURL of `http://mac.fritz.box:8000` the cert SAN list covered `mac.fritz.box` but not `macoauth.fritz.box`, so the speaker would resolve the OAuth host correctly (via the new DNS hijack) and then immediately fail the TLS handshake — Spotify / Amazon Music token refresh dies before reaching AfterTouch. getDomains now calls discovery.DeriveOAuthHostnames(serverURL) and discovery.DeriveOAuthHostnames(httpsServerURL), feeding the derived names into the SAN map alongside the existing entries. IP-based serverURLs continue to produce no derivation (the OAuth construction is unrecoverable for them — see the existing oauth_target_reachable health check). Tests in cmd/soundtouch-service/main_test.go lock in: - Hostname serverURL → derived OAuth variant present in SAN list. - IP serverURL → no malformed `192oauth.…` entry leaks in. Co-Authored-By: Claude Sonnet 4.6 --- cmd/soundtouch-service/main.go | 16 +++++++++++++ cmd/soundtouch-service/main_test.go | 36 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 32c68eb..263742e 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -747,6 +747,22 @@ func getDomains(serverURL, httpsServerURL, hostname string, extraHosts []string) domainsMap[strings.ToLower(u.Hostname())] = true } + // The speaker firmware constructs the OAuth host by appending `oauth` + // to the first label of the streaming hostname (see issue #337 and + // pkg/discovery/dns.go DeriveOAuthHostnames). The DNS hijack catches + // it; the TLS cert must also cover it, otherwise the speaker rejects + // the handshake and Spotify / Amazon Music OAuth dies before reaching + // AfterTouch. Derive once from each of serverURL and httpsServerURL — + // they typically share a hostname but a multi-homed deployment may + // differ. + for _, h := range discovery.DeriveOAuthHostnames(serverURL) { + domainsMap[h] = true + } + + for _, h := range discovery.DeriveOAuthHostnames(httpsServerURL) { + domainsMap[h] = true + } + // Explicit overrides / additions for multi-homed hosts, reverse proxies, // or browsing the admin UI via a LAN IP that isn't part of serverURL. for _, h := range extraHosts { diff --git a/cmd/soundtouch-service/main_test.go b/cmd/soundtouch-service/main_test.go index 8fa7d63..3f34cc2 100644 --- a/cmd/soundtouch-service/main_test.go +++ b/cmd/soundtouch-service/main_test.go @@ -151,3 +151,39 @@ func TestMergeTLSExtraHosts(t *testing.T) { }) } } + +func TestGetDomains_IncludesOAuthDerivation(t *testing.T) { + // Hostname-based serverURL: the derived OAuth variant must end up + // in the served TLS cert SAN list, otherwise the speaker rejects + // the TLS handshake on Spotify / Amazon Music token refresh. + got := getDomains("http://mac.fritz.box:8000", "https://mac.fritz.box:8443", "mac.fritz.box", nil) + + want := "macoauth.fritz.box" + if !contains(got, want) { + t.Errorf("expected SAN list to include %q (derived from serverURL), got: %v", want, got) + } +} + +func TestGetDomains_IPServerURLProducesNoOAuthDerivation(t *testing.T) { + // IP-based serverURL deliberately yields no derivation (the speaker's + // `oauth.` construction would be malformed for an + // IP and no DNS resolver can answer for it). The cert SAN list must + // not pretend to cover something that can never be queried. + got := getDomains("http://192.168.0.30:8000", "https://192.168.0.30:8443", "192.168.0.30", nil) + + for _, h := range got { + if h == "192oauth.168.0.30" { + t.Errorf("SAN list must not include malformed IP-derived OAuth name, got: %v", got) + } + } +} + +func contains(haystack []string, needle string) bool { + for _, h := range haystack { + if h == needle { + return true + } + } + + return false +}